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