diff --git a/CLAUDE.md b/CLAUDE.md index c334592..b0f28a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,10 +168,14 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`, Account wie verändert); append-only aus demselben Grund wie `finding`/`extraction`/`evidence_package` - `submission` — ein eingereichter Beitrag, Status, Zeitpunkte -- `asset` — hochgeladenes Standbild (optional bei der Prüfung), Pfad, - SHA-256; append-only aus demselben Grund wie `finding`/`extraction`/ - `evidence_package` — ein Beweisstück wird nicht nachträglich - ausgetauscht +- `asset` — hochgeladenes Standbild, Pfad, SHA-256; append-only aus + demselben Grund wie `finding`/`extraction`/`evidence_package` — ein + Beweisstück wird nicht nachträglich ausgetauscht. `purpose` = `initial` + (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 - `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere, 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 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 diff --git a/internal/store/asset.go b/internal/store/asset.go index fa6477b..0546500 100644 --- a/internal/store/asset.go +++ b/internal/store/asset.go @@ -9,14 +9,17 @@ import ( "github.com/jackc/pgx/v5" ) -// Asset ist eine zu einem Beitrag hochgeladene Datei (aktuell: das -// Standbild/der Screenshot). Append-only wie extraction/finding/ -// evidence_package — ein hochgeladenes Beweisstück wird nicht -// nachträglich ausgetauscht, siehe Migration. +// Asset ist eine zu einem Beitrag hochgeladene Datei. Append-only wie +// extraction/finding/evidence_package — ein hochgeladenes Beweisstück +// wird nicht nachträglich ausgetauscht, siehe Migration. Purpose +// 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 { ID string SubmissionID string Kind string + Purpose string Path string SHA256 string CreatedAt time.Time @@ -25,14 +28,14 @@ type Asset struct { // CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte // SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form, // 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 err := s.Pool.QueryRow(ctx, ` - INSERT INTO asset (submission_id, kind, path, sha256) - VALUES ($1, $2, $3, $4) - RETURNING id, submission_id, kind, path, sha256, created_at - `, submissionID, kind, path, sha256Hex).Scan( - &a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt, + INSERT INTO asset (submission_id, kind, purpose, path, sha256) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, submission_id, kind, purpose, path, sha256, created_at + `, submissionID, kind, purpose, path, sha256Hex).Scan( + &a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt, ) if err != nil { 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 -// eines Beitrags. Liefert ErrNotFound, wenn kein Asset hochgeladen wurde -// — das ist der Normalfall (ein Standbild ist optional), kein Fehler, -// den Aufrufer wie einen echten Datenbankfehler behandeln sollten. +// mit purpose="initial" eines Beitrags — bewusst ohne spätere +// "insights"-Assets, damit ein erneutes Archivieren immer denselben +// 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) { var a Asset 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 - WHERE submission_id = $1 + WHERE submission_id = $1 AND purpose = 'initial' ORDER BY created_at DESC LIMIT 1 `, 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) { return Asset{}, ErrNotFound @@ -63,3 +70,29 @@ func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID st } 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 +} diff --git a/internal/store/asset_test.go b/internal/store/asset_test.go index d1b326e..233aa85 100644 --- a/internal/store/asset_test.go +++ b/internal/store/asset_test.go @@ -17,11 +17,11 @@ func TestAssetCreateAndGetLatest(t *testing.T) { 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 { 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) } @@ -58,10 +58,10 @@ func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) { 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) } - 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 { 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) { s := openTestStore(t) ctx := context.Background() @@ -83,7 +136,7 @@ func TestAssetIsAppendOnly(t *testing.T) { if err != nil { 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 { t.Fatalf("CreateAsset: %v", err) } diff --git a/internal/store/migrations/0007_asset_purpose.down.sql b/internal/store/migrations/0007_asset_purpose.down.sql new file mode 100644 index 0000000..b93810c --- /dev/null +++ b/internal/store/migrations/0007_asset_purpose.down.sql @@ -0,0 +1 @@ +ALTER TABLE asset DROP COLUMN purpose; diff --git a/internal/store/migrations/0007_asset_purpose.up.sql b/internal/store/migrations/0007_asset_purpose.up.sql new file mode 100644 index 0000000..19df08a --- /dev/null +++ b/internal/store/migrations/0007_asset_purpose.up.sql @@ -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')); diff --git a/internal/web/archive_handlers.go b/internal/web/archive_handlers.go index b7e85a7..dba0cab 100644 --- a/internal/web/archive_handlers.go +++ b/internal/web/archive_handlers.go @@ -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 diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 3a010e6..1a83dae 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -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 } diff --git a/internal/web/insights_handlers_test.go b/internal/web/insights_handlers_test.go new file mode 100644 index 0000000..088f176 --- /dev/null +++ b/internal/web/insights_handlers_test.go @@ -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, `"`)] +} diff --git a/internal/web/insights_reminder.go b/internal/web/insights_reminder.go new file mode 100644 index 0000000..c8d99ed --- /dev/null +++ b/internal/web/insights_reminder.go @@ -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), + } +} diff --git a/internal/web/insights_reminder_test.go b/internal/web/insights_reminder_test.go new file mode 100644 index 0000000..6bb7259 --- /dev/null +++ b/internal/web/insights_reminder_test.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 3db80ad..3988fdd 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -71,8 +71,9 @@ type Store interface { DeleteSession(ctx context.Context, token string) error CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (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) + 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) 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/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant)) 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 /verbindungen", s.requirePage(s.handleConnectionsList)) mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 64129ea..5c6d548 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -69,7 +69,7 @@ type fakeStore struct { evidencePkgs map[string]store.EvidencePackage participants map[string]store.Participant 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, // wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle. @@ -87,7 +87,7 @@ func newFakeStore() *fakeStore { findings: map[string][]store.Finding{}, evidencePkgs: map[string]store.EvidencePackage{}, participants: map[string]store.Participant{}, - assets: map[string]store.Asset{}, + assets: map[string][]store.Asset{}, platformConnections: map[string]map[string]store.PlatformConnection{}, } } @@ -350,24 +350,40 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s 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() defer f.mu.Unlock() 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 } +// 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) { f.mu.Lock() defer f.mu.Unlock() - a, ok := f.assets[submissionID] - if !ok { + var latest store.Asset + found := false + for _, a := range f.assets[submissionID] { + if a.Purpose == "initial" { + latest = a + found = true + } + } + if !found { 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) { diff --git a/internal/web/templates/beitrag.html b/internal/web/templates/beitrag.html index 5059812..e3e06d7 100644 --- a/internal/web/templates/beitrag.html +++ b/internal/web/templates/beitrag.html @@ -35,6 +35,28 @@ {{end}} {{if .IsPublished}}
Nachweis-Dossier (PDF) herunterladen
+ +{{if .InsightsReminder.Show}} +{{.InsightsReminder.Message}}
+ +