// Package folderapi implementiert DOC-10s Backend-Voraussetzung: HTTP- // Endpunkte für Navigationsbaum, Brotkrumenpfad und Dokumentenliste. Ruft // ausschließlich internal/folder auf, keine eigene Baum-/Pfad-Logik. package folderapi import ( "encoding/json" "net/http" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/dms/internal/folder" ) type Server struct { pool *pgxpool.Pool } func NewServer(pool *pgxpool.Pool) *Server { return &Server{pool: pool} } func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /folders/children", s.handleChildren) mux.HandleFunc("GET /folders/{id}/children", s.handleChildren) mux.HandleFunc("GET /folders/{id}/breadcrumb", s.handleBreadcrumb) mux.HandleFunc("GET /folders/documents", s.handleDocuments) mux.HandleFunc("GET /folders/{id}/documents", s.handleDocuments) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) } // folderIDParam liefert nil für den Wurzelaufruf (Route ohne {id}, z. B. // "/folders/children"), sonst die Pfad-ID. func folderIDParam(r *http.Request) *string { id := r.PathValue("id") if id == "" { return nil } return &id } type folderDTO struct { ID string `json:"id"` ParentFolderID *string `json:"parent_folder_id"` Name string `json:"name"` } func toDTO(f folder.Folder) folderDTO { return folderDTO{ID: f.ID, ParentFolderID: f.ParentFolderID, Name: f.Name} } // Akzeptanzkriterium 1: Baum und Liste bleiben synchron — beide fragen // denselben Endpunkt-Satz gegen denselben Datenstand ab, kein separater // "Baum-Cache". func (s *Server) handleChildren(w http.ResponseWriter, r *http.Request) { children, err := folder.ListChildren(r.Context(), s.pool, folderIDParam(r)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } dtos := make([]folderDTO, len(children)) for i, f := range children { dtos[i] = toDTO(f) } writeJSON(w, dtos) } func (s *Server) handleBreadcrumb(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") trail, err := folder.Breadcrumb(r.Context(), s.pool, id) if err != nil { if err == folder.ErrNotFound { writeError(w, http.StatusNotFound, "ordner nicht gefunden") return } writeError(w, http.StatusInternalServerError, err.Error()) return } dtos := make([]folderDTO, len(trail)) for i, f := range trail { dtos[i] = toDTO(f) } writeJSON(w, dtos) } type documentDTO struct { ID string `json:"id"` Title string `json:"title"` SizeBytes *int64 `json:"size_bytes"` CreatedAt string `json:"created_at"` } // handleDocuments liefert die Dokumente EINES Ordners (nicht des gesamten // Teilbaums) mit optionaler Sortierung (?sort=title|created_at|size, // ?order=asc|desc — Akzeptanzkriterium: Sortierung/Spaltenwahl bleibt // Sache des Frontends, dieser Endpunkt liefert nur die Rohdaten sortiert). func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) { folderID := folderIDParam(r) sortCol := "created_at" switch r.URL.Query().Get("sort") { case "title": sortCol = "d.title" case "size": sortCol = "size_bytes" default: sortCol = "d.created_at" } order := "ASC" if strings.EqualFold(r.URL.Query().Get("order"), "desc") { order = "DESC" } // NUR whitelisted Spaltennamen (siehe switch oben) landen im SQL-Text // selbst (kein Sprintf mit Nutzereingabe) - Sortierparameter ist damit // injection-sicher trotz dynamischem ORDER BY. // DOC-17 Akzeptanzkriterium 1: ein laufender/fehlgeschlagener Scan // (revision_scans-Status ungleich "clean") verbirgt das Dokument aus // dieser normalen Ansicht. Fehlt die revision_scans-Zeile ganz (kein // Scan jemals eingereiht, z.B. bei Dokumenten aus der Zeit vor DOC-17), // bleibt das Dokument unverändert sichtbar — kein rückwirkendes // Verstecken bestehender Daten. baseQuery := ` SELECT d.id, d.title, fr.size_bytes, d.created_at FROM documents d LEFT JOIN file_revisions fr ON fr.id = d.current_revision_id WHERE d.deleted_at IS NULL AND d.trashed_at IS NULL AND NOT EXISTS (SELECT 1 FROM revision_scans rs WHERE rs.revision_id = d.current_revision_id AND rs.status <> 'clean') AND d.folder_id ` var rows pgx.Rows var err error if folderID == nil { rows, err = s.pool.Query(r.Context(), baseQuery+"IS NULL ORDER BY "+sortCol+" "+order) } else { rows, err = s.pool.Query(r.Context(), baseQuery+"= $1 ORDER BY "+sortCol+" "+order, *folderID) } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() docs := []documentDTO{} for rows.Next() { var d documentDTO var createdAt time.Time if err := rows.Scan(&d.ID, &d.Title, &d.SizeBytes, &createdAt); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } d.CreatedAt = createdAt.UTC().Format(time.RFC3339) docs = append(docs, d) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, docs) }