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:
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
@@ -55,20 +56,27 @@ type participantView struct {
|
||||
ApprovedAt string // leer, wenn noch nicht freigegeben
|
||||
}
|
||||
|
||||
type insightsAssetView struct {
|
||||
CreatedAt string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
type submissionDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
SubmissionID string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
Status string
|
||||
CreatedAt string
|
||||
CanArchive bool
|
||||
IsPublished bool
|
||||
DossierURL string
|
||||
Findings []findingView
|
||||
Participants []participantView
|
||||
Title string
|
||||
Nav navData
|
||||
SubmissionID string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
Status string
|
||||
CreatedAt string
|
||||
CanArchive bool
|
||||
IsPublished bool
|
||||
DossierURL string
|
||||
Findings []findingView
|
||||
Participants []participantView
|
||||
InsightsReminder insightsReminder
|
||||
InsightsAssets []insightsAssetView
|
||||
}
|
||||
|
||||
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
|
||||
@@ -126,17 +134,76 @@ func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
assets, err := s.store.ListAssetsForSubmission(ctx, sub.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Assets konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var insightsAssets []insightsAssetView
|
||||
hasInsightsAsset := false
|
||||
for _, a := range assets {
|
||||
if a.Purpose == "insights" {
|
||||
hasInsightsAsset = true
|
||||
insightsAssets = append(insightsAssets, insightsAssetView{
|
||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), SHA256: a.SHA256,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var reminder insightsReminder
|
||||
if sub.Status == "published" {
|
||||
if pkg, err := s.store.GetLatestEvidencePackage(ctx, sub.ID); err == nil {
|
||||
reminder = computeInsightsReminder(sub.PostType, pkg.CreatedAt, time.Now(), hasInsightsAsset)
|
||||
}
|
||||
}
|
||||
|
||||
data := submissionDetailData{
|
||||
Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
|
||||
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
|
||||
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
|
||||
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
|
||||
InsightsReminder: reminder, InsightsAssets: insightsAssets,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAddInsightsAsset speichert einen zusätzlichen Screenshot der
|
||||
// Kennzahlen (Insights) eines bereits veröffentlichten Beitrags — siehe
|
||||
// insightsReminder. Nutzt dieselbe Validierung wie das initiale
|
||||
// Standbild beim Prüfen (readUploadedAsset/storeAsset), nur mit
|
||||
// purpose="insights" statt "initial" und als eigener, jederzeit
|
||||
// wiederholbarer Upload statt einmalig beim Anlegen der Submission.
|
||||
func (s *Server) handleAddInsightsAsset(w http.ResponseWriter, r *http.Request) {
|
||||
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
asset, err := s.readUploadedAsset(r, "insights_standbild")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if asset == nil {
|
||||
http.Error(w, "kein Standbild hochgeladen", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.storeAsset(r.Context(), sub.ID, "insights", asset); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/beitraege/"+sub.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type participantListData struct {
|
||||
SubmissionID string
|
||||
Participants []participantView
|
||||
|
||||
Reference in New Issue
Block a user