Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd7831784 |
31
CLAUDE.md
31
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE asset DROP COLUMN purpose;
|
||||
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal 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'));
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
132
internal/web/insights_handlers_test.go
Normal file
132
internal/web/insights_handlers_test.go
Normal 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, `"`)]
|
||||
}
|
||||
47
internal/web/insights_reminder.go
Normal file
47
internal/web/insights_reminder.go
Normal 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),
|
||||
}
|
||||
}
|
||||
45
internal/web/insights_reminder_test.go
Normal file
45
internal/web/insights_reminder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
{{end}}
|
||||
{{if .IsPublished}}
|
||||
<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}}
|
||||
|
||||
<h2>Verantwortungsmatrix</h2>
|
||||
|
||||
Reference in New Issue
Block a user