Compare commits
2 Commits
77de1627c5
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d71b0d54 | ||
|
|
6df1d2961b |
@@ -27,8 +27,14 @@ type Finding struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFinding speichert ein Finding.
|
// CreateFinding speichert ein Finding. sources ist NOT NULL in der DB
|
||||||
|
// (TEXT[]) — ein nil-Slice (z. B. eine Regel ohne fundstelle-Eintrag)
|
||||||
|
// würde als SQL-NULL ankommen und mit einer wenig hilfreichen Constraint-
|
||||||
|
// Fehlermeldung abgelehnt; hier stattdessen auf eine leere Liste normiert.
|
||||||
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
|
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
|
||||||
|
if sources == nil {
|
||||||
|
sources = []string{}
|
||||||
|
}
|
||||||
var f Finding
|
var f Finding
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
|
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
|
||||||
|
|||||||
121
internal/store/participant.go
Normal file
121
internal/store/participant.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Participant ist ein Beteiligter an einer Submission (Verantwortungs-
|
||||||
|
// matrix). Anders als finding/extraction/evidence_package ist participant
|
||||||
|
// NICHT append-only — wer vorgegeben/freigegeben hat, kann sich klären
|
||||||
|
// oder korrigieren, ohne dass das ein Beweis-Eintrag ist.
|
||||||
|
type Participant struct {
|
||||||
|
ID string
|
||||||
|
SubmissionID string
|
||||||
|
Role string
|
||||||
|
Name string
|
||||||
|
Vorgegeben bool
|
||||||
|
Freigegeben bool
|
||||||
|
ApprovedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateParticipant fügt einen Beteiligten zu einer Submission hinzu.
|
||||||
|
func (s *Store) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (Participant, error) {
|
||||||
|
var p Participant
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO participant (submission_id, role, name, vorgegeben, freigegeben, approved_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, CASE WHEN $5 THEN now() ELSE NULL END)
|
||||||
|
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||||
|
`, submissionID, role, name, vorgegeben, freigegeben).Scan(
|
||||||
|
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Participant{}, fmt.Errorf("store: create participant: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListParticipants liefert alle Beteiligten einer Submission.
|
||||||
|
func (s *Store) ListParticipants(ctx context.Context, submissionID string) ([]Participant, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||||
|
FROM participant WHERE submission_id = $1 ORDER BY created_at
|
||||||
|
`, submissionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list participants: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var participants []Participant
|
||||||
|
for rows.Next() {
|
||||||
|
var p Participant
|
||||||
|
if err := rows.Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan participant: %w", err)
|
||||||
|
}
|
||||||
|
participants = append(participants, p)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list participants: %w", err)
|
||||||
|
}
|
||||||
|
return participants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetParticipant liest einen Beteiligten anhand seiner ID — u. a. um vor
|
||||||
|
// einem Update/Delete zu prüfen, zu welcher Submission (und damit zu
|
||||||
|
// welchem Account) er gehört.
|
||||||
|
func (s *Store) GetParticipant(ctx context.Context, id string) (Participant, error) {
|
||||||
|
var p Participant
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||||
|
FROM participant WHERE id = $1
|
||||||
|
`, id).Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Participant{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Participant{}, fmt.Errorf("store: get participant: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateParticipant setzt vorgegeben/freigegeben. approved_at wird beim
|
||||||
|
// ersten Wechsel zu freigegeben=true gesetzt und danach nicht mehr
|
||||||
|
// verändert (er hält fest, wann zuerst freigegeben wurde).
|
||||||
|
func (s *Store) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (Participant, error) {
|
||||||
|
var p Participant
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE participant
|
||||||
|
SET vorgegeben = $2,
|
||||||
|
freigegeben = $3,
|
||||||
|
approved_at = CASE WHEN $3 AND approved_at IS NULL THEN now() ELSE approved_at END
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||||
|
`, id, vorgegeben, freigegeben).Scan(
|
||||||
|
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Participant{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Participant{}, fmt.Errorf("store: update participant: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteParticipant entfernt einen Beteiligten (z. B. versehentlich
|
||||||
|
// falsch angelegt).
|
||||||
|
func (s *Store) DeleteParticipant(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM participant WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete participant: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
133
internal/store/participant_test.go
Normal file
133
internal/store/participant_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParticipantCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := s.CreateParticipant(ctx, sub.ID, "creator", "Max Mustermann", false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateParticipant: %v", err)
|
||||||
|
}
|
||||||
|
if p.ApprovedAt != nil {
|
||||||
|
t.Fatalf("ApprovedAt should be nil when freigegeben=false, got %v", p.ApprovedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListParticipants(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListParticipants: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != p.ID {
|
||||||
|
t.Fatalf("ListParticipants = %+v, want exactly the created participant", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetParticipant(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetParticipant: %v", err)
|
||||||
|
}
|
||||||
|
if got.SubmissionID != sub.ID {
|
||||||
|
t.Fatalf("GetParticipant.SubmissionID = %q, want %q", got.SubmissionID, sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateParticipant(ctx, p.ID, true, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateParticipant: %v", err)
|
||||||
|
}
|
||||||
|
if !updated.Vorgegeben || !updated.Freigegeben {
|
||||||
|
t.Fatalf("UpdateParticipant did not apply new flags: %+v", updated)
|
||||||
|
}
|
||||||
|
if updated.ApprovedAt == nil {
|
||||||
|
t.Fatal("expected ApprovedAt to be set once freigegeben became true")
|
||||||
|
}
|
||||||
|
firstApproval := *updated.ApprovedAt
|
||||||
|
|
||||||
|
// Ein erneutes Update (weiterhin freigegeben) darf approved_at nicht
|
||||||
|
// verschieben — es haelt fest, wann ZUERST freigegeben wurde.
|
||||||
|
updated2, err := s.UpdateParticipant(ctx, p.ID, true, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateParticipant (2): %v", err)
|
||||||
|
}
|
||||||
|
if !updated2.ApprovedAt.Equal(firstApproval) {
|
||||||
|
t.Fatalf("ApprovedAt changed on a no-op update: %v -> %v", firstApproval, *updated2.ApprovedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteParticipant(ctx, p.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteParticipant: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetParticipant(ctx, p.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateParticipantNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.UpdateParticipant(context.Background(), "00000000-0000-0000-0000-000000000000", true, true)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteParticipantNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
err := s.DeleteParticipant(context.Background(), "00000000-0000-0000-0000-000000000000")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSubmissionsForAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
subNoFindings, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "organic")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
subWithFinding, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "unmarked ad")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
// sources bewusst nil statt []string{} — CreateFinding muss das
|
||||||
|
// selbst abfangen (siehe Kommentar dort), nicht der Aufrufer.
|
||||||
|
if _, err := s.CreateFinding(ctx, subWithFinding.ID, nil, "WK-001", 1, "hoch", "t", "f", nil); err != nil {
|
||||||
|
t.Fatalf("CreateFinding: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Andere Mandanten duerfen nicht auftauchen.
|
||||||
|
otherAccID := testAccountID(t, s)
|
||||||
|
if _, err := s.CreateSubmission(ctx, otherAccID, "instagram", "reel", "anderer Mandant"); err != nil {
|
||||||
|
t.Fatalf("CreateSubmission (other account): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListSubmissionsForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSubmissionsForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("expected 2 submissions for this account, got %d: %+v", len(list), list)
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := map[string]store.SubmissionSummary{}
|
||||||
|
for _, s := range list {
|
||||||
|
byID[s.ID] = s
|
||||||
|
}
|
||||||
|
if byID[subNoFindings.ID].FindingCount != 0 || byID[subNoFindings.ID].HighestSeverity != "" {
|
||||||
|
t.Errorf("subNoFindings summary = %+v, want 0 findings and no severity", byID[subNoFindings.ID])
|
||||||
|
}
|
||||||
|
if byID[subWithFinding.ID].FindingCount != 1 || byID[subWithFinding.ID].HighestSeverity != "hoch" {
|
||||||
|
t.Errorf("subWithFinding summary = %+v, want 1 finding, severity hoch", byID[subWithFinding.ID])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,62 @@ func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error
|
|||||||
return sub, nil
|
return sub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmissionSummary ist eine Submission plus einer Kurzfassung ihrer
|
||||||
|
// aktuell gültigen Findings, wie sie eine Übersichtsliste braucht (ohne
|
||||||
|
// für jede Zeile extra ListCurrentFindings aufzurufen).
|
||||||
|
type SubmissionSummary struct {
|
||||||
|
Submission
|
||||||
|
FindingCount int
|
||||||
|
HighestSeverity string // "" wenn keine Findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubmissionsForAccount liefert alle Beiträge eines Mandanten,
|
||||||
|
// neueste zuerst, mit Findings-Kurzfassung.
|
||||||
|
func (s *Store) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]SubmissionSummary, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
s.id, s.account_id, s.platform, s.post_type, s.caption, s.status, s.created_at, s.updated_at,
|
||||||
|
COUNT(f.id) AS finding_count,
|
||||||
|
COALESCE(MAX(CASE f.severity WHEN 'hoch' THEN 3 WHEN 'mittel' THEN 2 WHEN 'niedrig' THEN 1 ELSE 0 END), 0) AS severity_rank
|
||||||
|
FROM submission s
|
||||||
|
LEFT JOIN finding f
|
||||||
|
ON f.submission_id = s.id
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
|
||||||
|
WHERE s.account_id = $1
|
||||||
|
GROUP BY s.id
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list submissions for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []SubmissionSummary
|
||||||
|
for rows.Next() {
|
||||||
|
var sub SubmissionSummary
|
||||||
|
var severityRank int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
||||||
|
&sub.FindingCount, &severityRank,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan submission summary: %w", err)
|
||||||
|
}
|
||||||
|
switch severityRank {
|
||||||
|
case 3:
|
||||||
|
sub.HighestSeverity = "hoch"
|
||||||
|
case 2:
|
||||||
|
sub.HighestSeverity = "mittel"
|
||||||
|
case 1:
|
||||||
|
sub.HighestSeverity = "niedrig"
|
||||||
|
}
|
||||||
|
out = append(out, sub)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list submissions for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
|
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
|
||||||
// anders als extraction/finding/evidence_package, NICHT append-only —
|
// anders als extraction/finding/evidence_package, NICHT append-only —
|
||||||
// der Lebenszyklus draft → checked → published → archived ist eine
|
// der Lebenszyklus draft → checked → published → archived ist eine
|
||||||
|
|||||||
237
internal/web/archive_handlers.go
Normal file
237
internal/web/archive_handlers.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type submissionListItem struct {
|
||||||
|
ID string
|
||||||
|
Platform string
|
||||||
|
PostType string
|
||||||
|
Status string
|
||||||
|
CreatedAt string
|
||||||
|
FindingCount int
|
||||||
|
HighestSeverity string
|
||||||
|
}
|
||||||
|
|
||||||
|
type submissionListData struct {
|
||||||
|
Title string
|
||||||
|
Submissions []submissionListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des
|
||||||
|
// angemeldeten Mandanten mit Kurzfassung der Findings.
|
||||||
|
func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := submissionListData{Title: "Beiträge"}
|
||||||
|
for _, sum := range summaries {
|
||||||
|
data.Submissions = append(data.Submissions, submissionListItem{
|
||||||
|
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
|
||||||
|
CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type participantView struct {
|
||||||
|
ID string
|
||||||
|
Role string
|
||||||
|
Name string
|
||||||
|
Vorgegeben bool
|
||||||
|
Freigegeben bool
|
||||||
|
ApprovedAt string // leer, wenn noch nicht freigegeben
|
||||||
|
}
|
||||||
|
|
||||||
|
type submissionDetailData struct {
|
||||||
|
Title string
|
||||||
|
SubmissionID string
|
||||||
|
Platform string
|
||||||
|
PostType string
|
||||||
|
Caption string
|
||||||
|
Status string
|
||||||
|
CreatedAt string
|
||||||
|
CanArchive bool
|
||||||
|
IsPublished bool
|
||||||
|
DossierURL string
|
||||||
|
Findings []findingView
|
||||||
|
Participants []participantView
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
|
||||||
|
// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag
|
||||||
|
// eines anderen Accounts wird wie ein nicht existierender behandelt.
|
||||||
|
func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) {
|
||||||
|
sub, err := s.store.GetSubmission(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return store.Submission{}, err
|
||||||
|
}
|
||||||
|
if sub.AccountID != currentUser(r).AccountID {
|
||||||
|
return store.Submission{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toParticipantViews(participants []store.Participant) []participantView {
|
||||||
|
views := make([]participantView, len(participants))
|
||||||
|
for i, p := range participants {
|
||||||
|
v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben}
|
||||||
|
if p.ApprovedAt != nil {
|
||||||
|
v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04")
|
||||||
|
}
|
||||||
|
views[i] = v
|
||||||
|
}
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings,
|
||||||
|
// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung.
|
||||||
|
func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
findings := make([]findingView, len(storeFindings))
|
||||||
|
for i, f := range storeFindings {
|
||||||
|
findings[i] = findingView{
|
||||||
|
RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity,
|
||||||
|
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
participants, err := s.store.ListParticipants(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := submissionDetailData{
|
||||||
|
Title: "Beitrag", 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),
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type participantListData struct {
|
||||||
|
SubmissionID string
|
||||||
|
Participants []participantView
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für
|
||||||
|
// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu
|
||||||
|
// geladen werden muss.
|
||||||
|
func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) {
|
||||||
|
participants, err := s.store.ListParticipants(r.Context(), submissionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil {
|
||||||
|
http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu.
|
||||||
|
func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role := r.FormValue("role")
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if role == "" || name == "" {
|
||||||
|
http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil {
|
||||||
|
http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderParticipantList(w, r, sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich
|
||||||
|
// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein
|
||||||
|
// fremder Mandant über eine erratene participant_id einen Beteiligten
|
||||||
|
// eines anderen Accounts ändern oder löschen.
|
||||||
|
func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) {
|
||||||
|
p, err := s.store.GetParticipant(r.Context(), participantID)
|
||||||
|
if err != nil {
|
||||||
|
return store.Participant{}, err
|
||||||
|
}
|
||||||
|
if p.SubmissionID != submissionIDFromURL {
|
||||||
|
return store.Participant{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil {
|
||||||
|
return store.Participant{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten.
|
||||||
|
func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
submissionID := r.PathValue("id")
|
||||||
|
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vorgegeben := r.FormValue("vorgegeben") == "on"
|
||||||
|
freigegeben := r.FormValue("freigegeben") == "on"
|
||||||
|
|
||||||
|
if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil {
|
||||||
|
http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderParticipantList(w, r, submissionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteParticipant entfernt einen Beteiligten.
|
||||||
|
func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
submissionID := r.PathValue("id")
|
||||||
|
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil {
|
||||||
|
http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderParticipantList(w, r, submissionID)
|
||||||
|
}
|
||||||
202
internal/web/archive_handlers_test.go
Normal file
202
internal/web/archive_handlers_test.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die
|
||||||
|
// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen
|
||||||
|
// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal
|
||||||
|
// auszuschreiben.
|
||||||
|
func checkAndReturnSubID(t *testing.T, s interface {
|
||||||
|
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||||
|
}, cookie *http.Cookie) string {
|
||||||
|
t.Helper()
|
||||||
|
form := checkForm()
|
||||||
|
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, `"`)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmissionListShowsOwnSubmissionsOnly(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")
|
||||||
|
|
||||||
|
subA := checkAndReturnSubID(t, s, cookieA)
|
||||||
|
_ = checkAndReturnSubID(t, s, cookieB)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookieA, "/beitraege")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, "/beitraege/"+subA) {
|
||||||
|
t.Errorf("expected Mandant A's own submission link, got: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mandant B hat genau einen eigenen Beitrag, keinen von A.
|
||||||
|
respB := getWithCookie(t, s, cookieB, "/beitraege")
|
||||||
|
if strings.Contains(respB.Body.String(), "/beitraege/"+subA) {
|
||||||
|
t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmissionListRequiresSession(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/beitraege", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
||||||
|
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
||||||
|
}})
|
||||||
|
subID := checkAndReturnSubID(t, s, cookie)
|
||||||
|
_ = fs
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, "WK-004") {
|
||||||
|
t.Errorf("expected the finding to be shown, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "/veroeffentlichen") {
|
||||||
|
t.Errorf("expected an archive option for a checked submission, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmissionDetailTenantIsolation(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")
|
||||||
|
subA := checkAndReturnSubID(t, s, cookieA)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticipantAddUpdateDeleteFlow(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||||
|
}})
|
||||||
|
subID := checkAndReturnSubID(t, s, cookie)
|
||||||
|
|
||||||
|
addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{
|
||||||
|
"role": {"creator"}, "name": {"Max Mustermann"},
|
||||||
|
})
|
||||||
|
if addResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(addResp.Body.String(), "Max Mustermann") {
|
||||||
|
t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||||
|
if !strings.Contains(detailResp.Body.String(), "Max Mustermann") {
|
||||||
|
t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beteiligten-ID aus dem Update-Formular extrahieren.
|
||||||
|
body := addResp.Body.String()
|
||||||
|
const marker = "/beteiligte/"
|
||||||
|
idx := strings.Index(body, marker)
|
||||||
|
if idx == -1 {
|
||||||
|
t.Fatalf("expected a participant action URL, got: %s", body)
|
||||||
|
}
|
||||||
|
rest := body[idx+len(marker):]
|
||||||
|
pID := rest[:strings.Index(rest, "/")]
|
||||||
|
|
||||||
|
updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{
|
||||||
|
"vorgegeben": {"on"}, "freigegeben": {"on"},
|
||||||
|
})
|
||||||
|
if updateResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(updateResp.Body.String(), "freigegeben am") {
|
||||||
|
t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(deleteResp.Body.String(), "Max Mustermann") {
|
||||||
|
t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticipantActionsRejectCrossTenantAccess(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")
|
||||||
|
subA := checkAndReturnSubID(t, s, cookieA)
|
||||||
|
|
||||||
|
// Mandant B darf für As Beitrag gar keinen Beteiligten anlegen.
|
||||||
|
addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{
|
||||||
|
"role": {"creator"}, "name": {"Fremd"},
|
||||||
|
})
|
||||||
|
if addResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateParticipant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{
|
||||||
|
"vorgegeben": {"on"},
|
||||||
|
})
|
||||||
|
if updateResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil {
|
||||||
|
t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,13 @@ type Store interface {
|
|||||||
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
||||||
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
||||||
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
|
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
|
||||||
|
ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error)
|
||||||
|
|
||||||
|
CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error)
|
||||||
|
ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error)
|
||||||
|
GetParticipant(ctx context.Context, id string) (store.Participant, error)
|
||||||
|
UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error)
|
||||||
|
DeleteParticipant(ctx context.Context, id string) error
|
||||||
|
|
||||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
||||||
@@ -98,6 +105,11 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
|
|||||||
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
|
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
|
||||||
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
||||||
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
|
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
|
||||||
|
mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList))
|
||||||
|
mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail))
|
||||||
|
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.Handle("GET /static/", http.FileServerFS(staticFS))
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
s.mux = mux
|
s.mux = mux
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type fakeStore struct {
|
|||||||
extractions map[string]store.Extraction
|
extractions map[string]store.Extraction
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -78,6 +79,7 @@ func newFakeStore() *fakeStore {
|
|||||||
extractions: map[string]store.Extraction{},
|
extractions: map[string]store.Extraction{},
|
||||||
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{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +249,106 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
|
|||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.SubmissionSummary
|
||||||
|
for _, sub := range f.submissions {
|
||||||
|
if sub.AccountID != accountID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum := store.SubmissionSummary{Submission: sub}
|
||||||
|
rank := 0
|
||||||
|
for _, finding := range f.findings[sub.ID] {
|
||||||
|
sum.FindingCount++
|
||||||
|
r := severityRank(finding.Severity)
|
||||||
|
if r > rank {
|
||||||
|
rank = r
|
||||||
|
sum.HighestSeverity = finding.Severity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, sum)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityRank(severity string) int {
|
||||||
|
switch severity {
|
||||||
|
case "hoch":
|
||||||
|
return 3
|
||||||
|
case "mittel":
|
||||||
|
return 2
|
||||||
|
case "niedrig":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
p := store.Participant{
|
||||||
|
ID: f.newID(), SubmissionID: submissionID, Role: role, Name: name,
|
||||||
|
Vorgegeben: vorgegeben, Freigegeben: freigegeben, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if freigegeben {
|
||||||
|
now := time.Now()
|
||||||
|
p.ApprovedAt = &now
|
||||||
|
}
|
||||||
|
f.participants[p.ID] = p
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Participant
|
||||||
|
for _, p := range f.participants {
|
||||||
|
if p.SubmissionID == submissionID {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetParticipant(ctx context.Context, id string) (store.Participant, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
p, ok := f.participants[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Participant{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
p, ok := f.participants[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Participant{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
p.Vorgegeben = vorgegeben
|
||||||
|
p.Freigegeben = freigegeben
|
||||||
|
if freigegeben && p.ApprovedAt == nil {
|
||||||
|
now := time.Now()
|
||||||
|
p.ApprovedAt = &now
|
||||||
|
}
|
||||||
|
f.participants[id] = p
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteParticipant(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.participants[id]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.participants, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// fakeTimestamper liefert einen offline erzeugten, strukturell gültigen
|
// fakeTimestamper liefert einen offline erzeugten, strukturell gültigen
|
||||||
// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime
|
// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime
|
||||||
// ihn parsen kann, ohne eine echte TSA zu brauchen.
|
// ihn parsen kann, ohne eine echte TSA zu brauchen.
|
||||||
|
|||||||
@@ -76,10 +76,23 @@ a {
|
|||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
||||||
dem Handy. */
|
dem Handy. */
|
||||||
form {
|
form {
|
||||||
@@ -232,6 +245,113 @@ nav button {
|
|||||||
border-left: 4px solid var(--color-niedrig);
|
border-left: 4px solid var(--color-niedrig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-border);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-published {
|
||||||
|
background: var(--color-niedrig-bg);
|
||||||
|
color: var(--color-niedrig);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-checked {
|
||||||
|
background: var(--color-mittel-bg);
|
||||||
|
color: var(--color-mittel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beitraege-liste {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beitraege-liste li a {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligte {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligter-kopf {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligter .rolle {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligter-form {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligter-form label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligter-form input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beteiligter form:last-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.entfernen {
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-hoch);
|
||||||
|
border: 1px solid var(--color-hoch-bg);
|
||||||
|
box-shadow: none;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
||||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
33
internal/web/templates/beitraege.html
Normal file
33
internal/web/templates/beitraege.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "beitraege"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Beiträge</h1>
|
||||||
|
|
||||||
|
{{if not .Submissions}}
|
||||||
|
<p class="hinweis">Noch keine Beiträge geprüft.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Submissions}}
|
||||||
|
<li>
|
||||||
|
<a href="/beitraege/{{.ID}}">
|
||||||
|
<strong>{{.Platform}}</strong> · {{.PostType}} · {{.CreatedAt}}
|
||||||
|
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||||
|
{{if .HighestSeverity}}
|
||||||
|
<span class="finding-{{.HighestSeverity}}">{{.FindingCount}} Finding(s), höchste: {{.HighestSeverity}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="keine-findings">keine Findings</span>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p><a href="/">Neuen Beitrag prüfen</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
63
internal/web/templates/beitrag.html
Normal file
63
internal/web/templates/beitrag.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{{define "beitrag"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/beitraege">← Alle Beiträge</a></p>
|
||||||
|
<h1>{{.Platform}} · {{.PostType}}</h1>
|
||||||
|
<p class="hinweis">Angelegt am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||||
|
<p>{{.Caption}}</p>
|
||||||
|
|
||||||
|
<h2>Findings</h2>
|
||||||
|
{{if .Findings}}
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Findings}}
|
||||||
|
<li class="finding finding-{{.Severity}}">
|
||||||
|
<strong>{{.RuleID}} v{{.Version}}</strong> ({{.Severity}}) — {{.Title}}
|
||||||
|
<p>Korrektur: {{.Fix}}</p>
|
||||||
|
{{if .Sources}}
|
||||||
|
<p>Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CanArchive}}
|
||||||
|
<form hx-post="/veroeffentlichen" hx-target="#archiv-ergebnis" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
|
||||||
|
<button type="submit">Als veröffentlicht markieren & archivieren</button>
|
||||||
|
</form>
|
||||||
|
<div id="archiv-ergebnis"></div>
|
||||||
|
{{end}}
|
||||||
|
{{if .IsPublished}}
|
||||||
|
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Verantwortungsmatrix</h2>
|
||||||
|
{{template "beteiligte-liste" .}}
|
||||||
|
|
||||||
|
<form hx-post="/beitraege/{{.SubmissionID}}/beteiligte" hx-target="#beteiligte" hx-swap="outerHTML">
|
||||||
|
<label for="role">Rolle</label>
|
||||||
|
<select id="role" name="role" required>
|
||||||
|
<option value="creator">Creator</option>
|
||||||
|
<option value="agentur">Agentur</option>
|
||||||
|
<option value="marke">Marke</option>
|
||||||
|
<option value="kanzlei">Kanzlei</option>
|
||||||
|
</select>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
<button type="submit">Beteiligten hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="disclaimer">
|
||||||
|
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
||||||
|
Prüfung im Einzelfall.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
28
internal/web/templates/beteiligte_liste.html
Normal file
28
internal/web/templates/beteiligte_liste.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "beteiligte-liste"}}
|
||||||
|
<div id="beteiligte">
|
||||||
|
{{if not .Participants}}
|
||||||
|
<p class="hinweis">Noch keine Beteiligten erfasst.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beteiligte">
|
||||||
|
{{range .Participants}}
|
||||||
|
<li class="beteiligter">
|
||||||
|
<div class="beteiligter-kopf">
|
||||||
|
<strong>{{.Name}}</strong> <span class="rolle">({{.Role}})</span>
|
||||||
|
</div>
|
||||||
|
<form class="beteiligter-form"
|
||||||
|
hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/aktualisieren"
|
||||||
|
hx-target="#beteiligte" hx-swap="outerHTML" hx-trigger="change">
|
||||||
|
<label><input type="checkbox" name="vorgegeben" {{if .Vorgegeben}}checked{{end}}> Vorgegeben</label>
|
||||||
|
<label><input type="checkbox" name="freigegeben" {{if .Freigegeben}}checked{{end}}> Freigegeben</label>
|
||||||
|
{{if .ApprovedAt}}<span class="hinweis">freigegeben am {{.ApprovedAt}}</span>{{end}}
|
||||||
|
</form>
|
||||||
|
<form hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/loeschen"
|
||||||
|
hx-target="#beteiligte" hx-swap="outerHTML" hx-confirm="Beteiligten wirklich entfernen?">
|
||||||
|
<button type="submit" class="entfernen">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav>
|
<nav>
|
||||||
|
<a href="/">Prüfen</a>
|
||||||
|
<a href="/beitraege">Beiträge</a>
|
||||||
<form method="post" action="/logout" style="display:inline">
|
<form method="post" action="/logout" style="display:inline">
|
||||||
<button type="submit">Abmelden</button>
|
<button type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user