Files
deklarix/internal/web/archive_handlers.go
noroot c9d71b0d54 feat: Archiv-Übersicht und Beteiligten-CRUD im Web-Layer
Bisher gab es nur Prüfen -> Archivieren, keine Möglichkeit, bereits
geprüfte Beiträge wieder anzusehen oder die Verantwortungsmatrix
(Beteiligte: wer hat vorgegeben, wer freigegeben) tatsächlich zu
pflegen — nur der Store-Layer dafür existierte schon.

Neu:
- GET /beitraege: Liste aller Beiträge des angemeldeten Mandanten
  (Plattform, Status, höchste Finding-Schwere) via
  ListSubmissionsForAccount.
- GET /beitraege/{id}: Detailseite mit Fakten, Findings, Archivieren-
  Aktion und Verantwortungsmatrix.
- POST .../beteiligte, .../beteiligte/{pid}/aktualisieren,
  .../beteiligte/{pid}/loeschen: echtes CRUD statt nur Ansicht, per
  htmx ohne Seiten-Reload.

Mandantentrennung wie beim bestehenden Archiv-Download: fremde
Beiträge und fremde Beteiligte (auch über eine erratene participant_id)
liefern 404, nicht 403 — sonst würde eine 403 die Existenz der Ressource
bei einem anderen Mandanten bestätigen.

Web-Store-Interface um die bereits fertigen Store-Methoden erweitert,
fakeStore in server_test.go entsprechend nachgezogen. Volle Testsuite
inkl. echter Postgres-Tests (./scripts/test.sh) grün.
2026-08-27 17:54:33 +02:00

238 lines
8.1 KiB
Go

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)
}