Files
archivdms/internal/api/saved_view_handlers.go
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

141 lines
5.5 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// handleListSavedViews serves GET /api/saved-views — the caller's own saved
// search views plus every view shared tenant-wide (is_shared). Pure read, not
// audited (consistent with the rest of the project).
func (s *Server) handleListSavedViews(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
views, err := s.store.ListSavedViews(r.Context(), *sess.TenantID, sess.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list saved views failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"views": views})
}
// savedViewRequest is the create/update body. Filters carries the serialized
// search query verbatim (see index.SearchQuery / handleSearchDocuments); it is
// stored as-is in the saved_views.filters JSONB column so the client can
// re-hydrate it 1:1 into a new search request.
type savedViewRequest struct {
Name string `json:"name"`
Filters json.RawMessage `json:"filters"`
IsShared bool `json:"is_shared"`
}
// handleCreateSavedView serves POST /api/saved-views.
func (s *Server) handleCreateSavedView(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req savedViewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
// Default an empty/omitted filters payload to an empty JSON object so the
// NOT NULL JSONB column always receives valid JSON.
if len(req.Filters) == 0 {
req.Filters = json.RawMessage("{}")
}
view, err := s.store.CreateSavedView(r.Context(), *sess.TenantID, sess.UserID, name, req.Filters, req.IsShared)
if err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
writeError(w, http.StatusInternalServerError, "create saved view failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventSavedViewCreate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "saved_view_id=" + strconv.FormatInt(view.ID, 10),
})
writeJSON(w, http.StatusCreated, view)
}
// handleUpdateSavedView serves PATCH /api/saved-views/{id}. Only the view's
// creator may update it (403 otherwise).
func (s *Server) handleUpdateSavedView(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid saved view id")
return
}
var req savedViewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if len(req.Filters) == 0 {
req.Filters = json.RawMessage("{}")
}
if err := s.store.UpdateSavedView(r.Context(), id, *sess.TenantID, sess.UserID, name, req.Filters, req.IsShared); err != nil {
status, msg := savedViewErrStatus(err, "update saved view failed")
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// handleDeleteSavedView serves DELETE /api/saved-views/{id}. Only the view's
// creator may delete it (403 otherwise).
func (s *Server) handleDeleteSavedView(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid saved view id")
return
}
if err := s.store.DeleteSavedView(r.Context(), id, *sess.TenantID, sess.UserID); err != nil {
status, msg := savedViewErrStatus(err, "delete saved view failed")
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// savedViewErrStatus maps store errors to an HTTP status + message: not-found
// -> 404, forbidden (exists but owned by another user) -> 403, else 500.
func savedViewErrStatus(err error, defaultMsg string) (int, string) {
switch {
case errors.Is(err, storage.ErrSavedViewNotFound):
return http.StatusNotFound, "saved view not found"
case errors.Is(err, storage.ErrSavedViewForbidden):
return http.StatusForbidden, "not allowed to modify this saved view"
default:
return http.StatusInternalServerError, defaultMsg
}
}