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).
307 lines
10 KiB
Go
307 lines
10 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
type submissionListItem struct {
|
|
ID string
|
|
Platform string
|
|
PostType string
|
|
Status string
|
|
CreatedAt string
|
|
FindingCount int
|
|
HighestSeverity string
|
|
}
|
|
|
|
type submissionListData struct {
|
|
Title string
|
|
Nav navData
|
|
Submissions []submissionListItem
|
|
}
|
|
|
|
// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des
|
|
// angemeldeten Mandanten mit Kurzfassung der Findings.
|
|
func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID)
|
|
if err != nil {
|
|
http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
data := submissionListData{Title: "Beiträge", Nav: navFor(r)}
|
|
for _, sum := range summaries {
|
|
data.Submissions = append(data.Submissions, submissionListItem{
|
|
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
|
|
CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"),
|
|
FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity,
|
|
})
|
|
}
|
|
|
|
if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type participantView struct {
|
|
ID string
|
|
Role string
|
|
Name string
|
|
Vorgegeben bool
|
|
Freigegeben bool
|
|
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
|
|
InsightsReminder insightsReminder
|
|
InsightsAssets []insightsAssetView
|
|
}
|
|
|
|
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
|
|
// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag
|
|
// eines anderen Accounts wird wie ein nicht existierender behandelt.
|
|
func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) {
|
|
sub, err := s.store.GetSubmission(r.Context(), id)
|
|
if err != nil {
|
|
return store.Submission{}, err
|
|
}
|
|
if sub.AccountID != currentUser(r).AccountID {
|
|
return store.Submission{}, store.ErrNotFound
|
|
}
|
|
return sub, nil
|
|
}
|
|
|
|
func toParticipantViews(participants []store.Participant) []participantView {
|
|
views := make([]participantView, len(participants))
|
|
for i, p := range participants {
|
|
v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben}
|
|
if p.ApprovedAt != nil {
|
|
v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04")
|
|
}
|
|
views[i] = v
|
|
}
|
|
return views
|
|
}
|
|
|
|
// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings,
|
|
// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung.
|
|
func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID)
|
|
if err != nil {
|
|
http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
findings := make([]findingView, len(storeFindings))
|
|
for i, f := range storeFindings {
|
|
findings[i] = findingView{
|
|
RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity,
|
|
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
|
}
|
|
}
|
|
|
|
participants, err := s.store.ListParticipants(ctx, sub.ID)
|
|
if err != nil {
|
|
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
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
|
|
}
|
|
|
|
// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für
|
|
// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu
|
|
// geladen werden muss.
|
|
func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) {
|
|
participants, err := s.store.ListParticipants(r.Context(), submissionID)
|
|
if err != nil {
|
|
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)}
|
|
if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil {
|
|
http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu.
|
|
func (s *Server) handleAddParticipant(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
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
role := r.FormValue("role")
|
|
name := r.FormValue("name")
|
|
if role == "" || name == "" {
|
|
http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil {
|
|
http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.renderParticipantList(w, r, sub.ID)
|
|
}
|
|
|
|
// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich
|
|
// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein
|
|
// fremder Mandant über eine erratene participant_id einen Beteiligten
|
|
// eines anderen Accounts ändern oder löschen.
|
|
func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) {
|
|
p, err := s.store.GetParticipant(r.Context(), participantID)
|
|
if err != nil {
|
|
return store.Participant{}, err
|
|
}
|
|
if p.SubmissionID != submissionIDFromURL {
|
|
return store.Participant{}, store.ErrNotFound
|
|
}
|
|
if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil {
|
|
return store.Participant{}, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten.
|
|
func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
|
|
submissionID := r.PathValue("id")
|
|
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
|
if err != nil {
|
|
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
vorgegeben := r.FormValue("vorgegeben") == "on"
|
|
freigegeben := r.FormValue("freigegeben") == "on"
|
|
|
|
if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil {
|
|
http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.renderParticipantList(w, r, submissionID)
|
|
}
|
|
|
|
// handleDeleteParticipant entfernt einen Beteiligten.
|
|
func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
|
|
submissionID := r.PathValue("id")
|
|
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
|
if err != nil {
|
|
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil {
|
|
http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.renderParticipantList(w, r, submissionID)
|
|
}
|