Compare commits

...

1 Commits

Author SHA1 Message Date
noroot
6fd7831784 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).
2026-08-28 10:10:44 +02:00
13 changed files with 512 additions and 56 deletions

View File

@@ -168,10 +168,14 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
Account wie verändert); append-only aus demselben Grund wie Account wie verändert); append-only aus demselben Grund wie
`finding`/`extraction`/`evidence_package` `finding`/`extraction`/`evidence_package`
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte - `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
- `asset` — hochgeladenes Standbild (optional bei der Prüfung), Pfad, - `asset` — hochgeladenes Standbild, Pfad, SHA-256; append-only aus
SHA-256; append-only aus demselben Grund wie `finding`/`extraction`/ demselben Grund wie `finding`/`extraction`/`evidence_package` — ein
`evidence_package` — ein Beweisstück wird nicht nachträglich Beweisstück wird nicht nachträglich ausgetauscht. `purpose` = `initial`
ausgetauscht (das Beweisfoto beim Prüfen, optional) oder `insights` (siehe
„Insights-Erinnerung" unten; ein Beitrag kann mehrere `insights`-Assets
über die Zeit bekommen). `GetLatestAssetForSubmission` berücksichtigt
nur `initial`, damit ein späterer Insights-Upload nie den beim
Archivieren referenzierten Original-Screenshot verdrängt
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version - `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere, - `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert, Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
@@ -243,6 +247,25 @@ echte API verifiziert — vor dem ersten echten Verbindungsversuch mit
realen Credentials die Konstanten in `internal/socialconnect/*.go` noch realen Credentials die Konstanten in `internal/socialconnect/*.go` noch
einmal gegen die dann aktuelle Meta-/TikTok-Dokumentation prüfen. einmal gegen die dann aktuelle Meta-/TikTok-Dokumentation prüfen.
**Insights-Erinnerung:** Story-Insights hält Instagram nach eigener
Aussage nur 24 Stunden vor — danach sind sie auch über den offiziellen
Datenexport nicht mehr zu bekommen, und der ursprüngliche Standbild-
Screenshot beim Prüfen (der direkt beim Veröffentlichen entsteht, bevor
nennenswerte Kennzahlen existieren) kann sie naturgemäß nicht erfassen.
`GET /beitraege/{id}` zeigt deshalb bei veröffentlichten `story`-
Beiträgen eine Erinnerung, solange keine `insights`-Asset existiert
(`internal/web/insights_reminder.go`, `computeInsightsReminder` —
reine Produktentscheidung zum Erinnerungs-Timing, keine Rechtsnorm,
daher bewusst nicht in `rules/*.yaml`). `POST /beitraege/{id}/insights`
speichert einen zusätzlichen Screenshot als `asset` mit
`purpose='insights'`, gehasht wie jedes andere Beweisstück — aber
NICHT im Metadaten-Hash des ursprünglichen Dossiers enthalten (das
wird beim Archivieren einmalig fixiert). Bewusst nur ein In-App-
Banner in dieser ersten Ausbaustufe, kein Mail-/Push-Versand — dafür
fehlt aktuell ein SMTP-Relay/Versanddienst; vor einer echten
Benachrichtigung per E-Mail ist das eine offene Rückfrage (welcher
Versanddienst, welche Absenderdomain/SPF/DKIM).
--- ---
## Go Commands ## Go Commands

View File

@@ -9,14 +9,17 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
// Asset ist eine zu einem Beitrag hochgeladene Datei (aktuell: das // Asset ist eine zu einem Beitrag hochgeladene Datei. Append-only wie
// Standbild/der Screenshot). Append-only wie extraction/finding/ // extraction/finding/evidence_package — ein hochgeladenes Beweisstück
// evidence_package — ein hochgeladenes Beweisstück wird nicht // wird nicht nachträglich ausgetauscht, siehe Migration. Purpose
// nachträglich ausgetauscht, siehe Migration. // unterscheidet das ursprüngliche Beweisfoto beim Prüfen ("initial")
// von einem späteren Nachweis flüchtiger Kennzahlen ("insights") —
// siehe Migration 0007 und CLAUDE.md, Abschnitt Insights-Erinnerung.
type Asset struct { type Asset struct {
ID string ID string
SubmissionID string SubmissionID string
Kind string Kind string
Purpose string
Path string Path string
SHA256 string SHA256 string
CreatedAt time.Time CreatedAt time.Time
@@ -25,14 +28,14 @@ type Asset struct {
// CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte // CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte
// SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form, // SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form,
// in der evidence_package.SHA256 seinen Hash speichert. // in der evidence_package.SHA256 seinen Hash speichert.
func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (Asset, error) { func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (Asset, error) {
var a Asset var a Asset
err := s.Pool.QueryRow(ctx, ` err := s.Pool.QueryRow(ctx, `
INSERT INTO asset (submission_id, kind, path, sha256) INSERT INTO asset (submission_id, kind, purpose, path, sha256)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING id, submission_id, kind, path, sha256, created_at RETURNING id, submission_id, kind, purpose, path, sha256, created_at
`, submissionID, kind, path, sha256Hex).Scan( `, submissionID, kind, purpose, path, sha256Hex).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt, &a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
) )
if err != nil { if err != nil {
return Asset{}, fmt.Errorf("store: create asset: %w", err) return Asset{}, fmt.Errorf("store: create asset: %w", err)
@@ -41,19 +44,23 @@ func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha25
} }
// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset // GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset
// eines Beitrags. Liefert ErrNotFound, wenn kein Asset hochgeladen wurde // mit purpose="initial" eines Beitrags — bewusst ohne spätere
// — das ist der Normalfall (ein Standbild ist optional), kein Fehler, // "insights"-Assets, damit ein erneutes Archivieren immer denselben
// den Aufrufer wie einen echten Datenbankfehler behandeln sollten. // ursprünglichen Beweis referenziert, egal wie viele Insights-
// Screenshots danach noch hinzukommen. Liefert ErrNotFound, wenn keins
// hochgeladen wurde — das ist der Normalfall (ein Standbild ist
// optional), kein Fehler, den Aufrufer wie einen echten
// Datenbankfehler behandeln sollten.
func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) { func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) {
var a Asset var a Asset
err := s.Pool.QueryRow(ctx, ` err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, kind, path, sha256, created_at SELECT id, submission_id, kind, purpose, path, sha256, created_at
FROM asset FROM asset
WHERE submission_id = $1 WHERE submission_id = $1 AND purpose = 'initial'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
`, submissionID).Scan( `, submissionID).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt, &a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
) )
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Asset{}, ErrNotFound return Asset{}, ErrNotFound
@@ -63,3 +70,29 @@ func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID st
} }
return a, nil return a, nil
} }
// ListAssetsForSubmission liefert alle Assets eines Beitrags
// (initiales Standbild und alle Insights-Nachweise), älteste zuerst.
func (s *Store) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]Asset, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, submission_id, kind, purpose, path, sha256, created_at
FROM asset WHERE submission_id = $1 ORDER BY created_at
`, submissionID)
if err != nil {
return nil, fmt.Errorf("store: list assets for submission: %w", err)
}
defer rows.Close()
var out []Asset
for rows.Next() {
var a Asset
if err := rows.Scan(&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan asset: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list assets for submission: %w", err)
}
return out, nil
}

View File

@@ -17,11 +17,11 @@ func TestAssetCreateAndGetLatest(t *testing.T) {
t.Fatalf("CreateSubmission: %v", err) t.Fatalf("CreateSubmission: %v", err)
} }
a, err := s.CreateAsset(ctx, sub.ID, "image", "/var/lib/deklarix/assets/abc.jpg", "deadbeef") a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
if err != nil { if err != nil {
t.Fatalf("CreateAsset: %v", err) t.Fatalf("CreateAsset: %v", err)
} }
if a.SubmissionID != sub.ID || a.Kind != "image" { if a.SubmissionID != sub.ID || a.Kind != "image" || a.Purpose != "initial" {
t.Fatalf("CreateAsset = %+v, unerwartete Werte", a) t.Fatalf("CreateAsset = %+v, unerwartete Werte", a)
} }
@@ -58,10 +58,10 @@ func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
t.Fatalf("CreateSubmission: %v", err) t.Fatalf("CreateSubmission: %v", err)
} }
if _, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/erstes.jpg", "erstehash"); err != nil { if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/erstes.jpg", "erstehash"); err != nil {
t.Fatalf("CreateAsset (1): %v", err) t.Fatalf("CreateAsset (1): %v", err)
} }
second, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/zweites.jpg", "zweitehash") second, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/zweites.jpg", "zweitehash")
if err != nil { if err != nil {
t.Fatalf("CreateAsset (2): %v", err) t.Fatalf("CreateAsset (2): %v", err)
} }
@@ -75,6 +75,59 @@ func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
} }
} }
func TestGetLatestAssetForSubmissionIgnoresInsightsAssets(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
initial, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "initialhash")
if err != nil {
t.Fatalf("CreateAsset (initial): %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "insightshash"); err != nil {
t.Fatalf("CreateAsset (insights): %v", err)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != initial.ID {
t.Fatalf("expected GetLatestAssetForSubmission to keep returning the initial asset even after an insights asset was added later, got %+v", got)
}
}
func TestListAssetsForSubmissionReturnsAllPurposes(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "h1"); err != nil {
t.Fatalf("CreateAsset (initial): %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "h2"); err != nil {
t.Fatalf("CreateAsset (insights): %v", err)
}
list, err := s.ListAssetsForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("ListAssetsForSubmission: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 assets, got %d: %+v", len(list), list)
}
if list[0].Purpose != "initial" || list[1].Purpose != "insights" {
t.Fatalf("expected initial before insights (created_at order), got %+v", list)
}
}
func TestAssetIsAppendOnly(t *testing.T) { func TestAssetIsAppendOnly(t *testing.T) {
s := openTestStore(t) s := openTestStore(t)
ctx := context.Background() ctx := context.Background()
@@ -83,7 +136,7 @@ func TestAssetIsAppendOnly(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CreateSubmission: %v", err) t.Fatalf("CreateSubmission: %v", err)
} }
a, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/x.jpg", "hash") a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/x.jpg", "hash")
if err != nil { if err != nil {
t.Fatalf("CreateAsset: %v", err) t.Fatalf("CreateAsset: %v", err)
} }

View File

@@ -0,0 +1 @@
ALTER TABLE asset DROP COLUMN purpose;

View File

@@ -0,0 +1,9 @@
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
-- angelegten Zeilen unverändert.
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
CHECK (purpose IN ('initial', 'insights'));

View File

@@ -2,6 +2,7 @@ package web
import ( import (
"net/http" "net/http"
"time"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
) )
@@ -55,20 +56,27 @@ type participantView struct {
ApprovedAt string // leer, wenn noch nicht freigegeben ApprovedAt string // leer, wenn noch nicht freigegeben
} }
type insightsAssetView struct {
CreatedAt string
SHA256 string
}
type submissionDetailData struct { type submissionDetailData struct {
Title string Title string
Nav navData Nav navData
SubmissionID string SubmissionID string
Platform string Platform string
PostType string PostType string
Caption string Caption string
Status string Status string
CreatedAt string CreatedAt string
CanArchive bool CanArchive bool
IsPublished bool IsPublished bool
DossierURL string DossierURL string
Findings []findingView Findings []findingView
Participants []participantView Participants []participantView
InsightsReminder insightsReminder
InsightsAssets []insightsAssetView
} }
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit. // 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 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{ data := submissionDetailData{
Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType, 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"), Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published", CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants), DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
InsightsReminder: reminder, InsightsAssets: insightsAssets,
} }
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil { if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) 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 { type participantListData struct {
SubmissionID string SubmissionID string
Participants []participantView Participants []participantView

View File

@@ -34,11 +34,11 @@ type uploadedAsset struct {
sha256Hex string 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, // (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe. // ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) { func (s *Server) readUploadedAsset(r *http.Request, fieldName string) (*uploadedAsset, error) {
file, header, err := r.FormFile("standbild") file, header, err := r.FormFile(fieldName)
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B. // ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch // ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile. // 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 // storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und
// speichert die Asset-Zeile. // speichert die Asset-Zeile. purpose ist "initial" (das Beweisfoto beim
func (s *Server) storeAsset(ctx context.Context, submissionID string, ua *uploadedAsset) error { // 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 { if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err) 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 { if err := os.WriteFile(path, ua.data, 0o640); err != nil {
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err) 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 fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
} }
return nil return nil
@@ -145,7 +151,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
asset, err := s.readUploadedAsset(r) asset, err := s.readUploadedAsset(r, "standbild")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -172,7 +178,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
} }
if asset != nil { 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }

View File

@@ -0,0 +1,132 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
// postInsightsUpload lädt einen Insights-Screenshot für einen Beitrag hoch.
func postInsightsUpload(t *testing.T, s *web.Server, cookie *http.Cookie, submissionID string, imageBytes []byte) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="insights_standbild"; filename="insights.png"`},
"Content-Type": {"image/png"},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(imageBytes); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("multipart Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/beitraege/"+submissionID+"/insights", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestStorySubmissionShowsInsightsReminderAfterArchiving(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
if archiveResp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
}
body := detailResp.Body.String()
if !strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected an insights reminder for a freshly archived story, got: %s", body)
}
_ = fs
}
func TestInsightsUploadClearsReminder(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
if resp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}); resp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", resp.Code, resp.Body.String())
}
uploadResp := postInsightsUpload(t, s, cookie, subID, tinyPNG)
if uploadResp.Code != http.StatusSeeOther {
t.Fatalf("insights upload status = %d, want 303, body: %s", uploadResp.Code, uploadResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
body := detailResp.Body.String()
if strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected the reminder to be gone after securing insights, got: %s", body)
}
if !strings.Contains(body, "Gesicherte Insights-Nachweise") {
t.Errorf("expected the secured insights section, got: %s", body)
}
}
func TestInsightsUploadRejectsForeignSubmission(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subID := checkAndReturnSubID2(t, s, cookieA, "story")
resp := postInsightsUpload(t, s, cookieB, subID, tinyPNG)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant insights upload status = %d, want 404", resp.Code)
}
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
t.Fatal("expected no asset from a rejected cross-tenant upload")
}
}
// checkAndReturnSubID2 ist checkAndReturnSubID mit ueberschreibbarem post_type.
func checkAndReturnSubID2(t *testing.T, s interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}, cookie *http.Cookie, postType string) string {
t.Helper()
form := checkForm("post_type", postType)
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
const marker = `name="submission_id" value="`
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a submission_id field in the result, got: %s", body)
}
rest := body[idx+len(marker):]
return rest[:strings.Index(rest, `"`)]
}

View File

@@ -0,0 +1,47 @@
package web
import (
"fmt"
"time"
)
// storyInsightsWindow ist das Zeitfenster, in dem Instagram nach
// eigener Aussage Story-Insights überhaupt vorhält — danach sind sie
// auch über den offiziellen Datenexport nicht mehr zu bekommen. Reine
// Produktentscheidung (Erinnerungs-Timing), keine Rechtsnorm — bewusst
// hier als Konstante und nicht in rules/*.yaml, das ist ausschließlich
// für die Kennzeichnungsprüfung reserviert (siehe CLAUDE.md).
const storyInsightsWindow = 24 * time.Hour
// insightsReminder beschreibt, ob und wie dringend eine Erinnerung
// angezeigt werden soll, die Kennzahlen (Insights) eines veröffentlichten
// Beitrags per Screenshot zu sichern, bevor sie unwiederbringlich
// verschwinden.
type insightsReminder struct {
Show bool
Urgent bool // Fenster läuft noch
Expired bool // Fenster ist wahrscheinlich schon vorbei
Message string
}
// computeInsightsReminder berechnet den Erinnerungsstatus. Nur für
// "story"-Beiträge relevant (siehe storyInsightsWindow); alle anderen
// Beitragstypen bekommen keine Erinnerung, da ihre Kennzahlen nicht auf
// dieselbe Art flüchtig sind.
func computeInsightsReminder(postType string, publishedAt time.Time, now time.Time, hasInsightsAsset bool) insightsReminder {
if postType != "story" || hasInsightsAsset || publishedAt.IsZero() {
return insightsReminder{}
}
elapsed := now.Sub(publishedAt)
if elapsed >= storyInsightsWindow {
return insightsReminder{
Show: true, Expired: true,
Message: "Das Zeitfenster für Story-Insights ist bei Instagram nach eigener Aussage abgelaufen (24 Stunden) — die Kennzahlen sind wahrscheinlich nicht mehr abrufbar.",
}
}
remainingHours := int((storyInsightsWindow - elapsed).Hours()) + 1
return insightsReminder{
Show: true, Urgent: true,
Message: fmt.Sprintf("Noch ca. %d Stunde(n), um die Story-Insights zu sichern, bevor sie bei Instagram verschwinden.", remainingHours),
}
}

View File

@@ -0,0 +1,45 @@
package web
import (
"testing"
"time"
)
func TestComputeInsightsReminderOnlyForStory(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("feed", now.Add(-time.Hour), now, false)
if r.Show {
t.Errorf("expected no reminder for a non-story post type, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWhenAlreadySecured(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-time.Hour), now, true)
if r.Show {
t.Errorf("expected no reminder once an insights asset already exists, got %+v", r)
}
}
func TestComputeInsightsReminderUrgentWithinWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-2*time.Hour), now, false)
if !r.Show || !r.Urgent || r.Expired {
t.Fatalf("expected an urgent, non-expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderExpiredAfterWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-25*time.Hour), now, false)
if !r.Show || !r.Expired || r.Urgent {
t.Fatalf("expected an expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWithoutPublishTime(t *testing.T) {
r := computeInsightsReminder("story", time.Time{}, time.Now(), false)
if r.Show {
t.Errorf("expected no reminder without a known publish time, got %+v", r)
}
}

View File

@@ -71,8 +71,9 @@ type Store interface {
DeleteSession(ctx context.Context, token string) error DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error)
UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error)
ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error)
@@ -133,6 +134,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant)) mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant)) mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant)) mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
mux.HandleFunc("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset))
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList) mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList)) mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList))
mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart)) mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart))

View File

@@ -69,7 +69,7 @@ type fakeStore struct {
evidencePkgs map[string]store.EvidencePackage evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant participants map[string]store.Participant
auditLog []store.AuditEntry 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, // platformConnections ist verschachtelt nach accountID -> platform,
// wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle. // wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle.
@@ -87,7 +87,7 @@ func newFakeStore() *fakeStore {
findings: map[string][]store.Finding{}, findings: map[string][]store.Finding{},
evidencePkgs: map[string]store.EvidencePackage{}, evidencePkgs: map[string]store.EvidencePackage{},
participants: map[string]store.Participant{}, participants: map[string]store.Participant{},
assets: map[string]store.Asset{}, assets: map[string][]store.Asset{},
platformConnections: map[string]map[string]store.PlatformConnection{}, platformConnections: map[string]map[string]store.PlatformConnection{},
} }
} }
@@ -350,24 +350,40 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
return pkg, nil 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() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
a := store.Asset{ 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 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) { func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
a, ok := f.assets[submissionID] var latest store.Asset
if !ok { found := false
for _, a := range f.assets[submissionID] {
if a.Purpose == "initial" {
latest = a
found = true
}
}
if !found {
return store.Asset{}, store.ErrNotFound 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) { func (f *fakeStore) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) {

View File

@@ -35,6 +35,28 @@
{{end}} {{end}}
{{if .IsPublished}} {{if .IsPublished}}
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p> <p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
{{if .InsightsReminder.Show}}
<div class="{{if .InsightsReminder.Expired}}fehler{{else}}rueckfrage{{end}}">
<p>{{.InsightsReminder.Message}}</p>
<form method="post" action="/beitraege/{{.SubmissionID}}/insights" enctype="multipart/form-data">
<label for="insights_standbild">Insights-Screenshot</label>
<input type="file" id="insights_standbild" name="insights_standbild" accept="image/*" required>
<button type="submit">Insights jetzt sichern</button>
</form>
</div>
{{end}}
{{if .InsightsAssets}}
<h2>Gesicherte Insights-Nachweise</h2>
<ul class="beteiligte">
{{range .InsightsAssets}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — SHA-256: {{.SHA256}}</div>
</li>
{{end}}
</ul>
{{end}}
{{end}} {{end}}
<h2>Verantwortungsmatrix</h2> <h2>Verantwortungsmatrix</h2>