package api import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "archivmail/internal/audit" "archivmail/internal/storage" ) // handleListSavedSearches returns all saved searches for the current user. // GET /api/searches/saved func (s *Server) handleListSavedSearches(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) tenantID := tenantFromCtx(r.Context()) if tenantID == nil { writeError(w, http.StatusBadRequest, "tenant context required") return } searches, err := s.store.ListSavedSearches(r.Context(), sess.UserID, *tenantID) if err != nil { s.logger.Error("saved_searches: list failed", "err", err) writeError(w, http.StatusInternalServerError, "failed to list saved searches") return } if searches == nil { searches = []storage.SavedSearch{} } writeJSON(w, http.StatusOK, searches) } // handleCreateSavedSearch creates a new saved search. // POST /api/searches/saved // Body: {"name": "...", "query": {...}} func (s *Server) handleCreateSavedSearch(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) tenantID := tenantFromCtx(r.Context()) if tenantID == nil { writeError(w, http.StatusBadRequest, "tenant context required") return } var body struct { Name string `json:"name"` Query json.RawMessage `json:"query"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } name := strings.TrimSpace(body.Name) if name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if len(name) > 200 { writeError(w, http.StatusBadRequest, "name too long (max 200 chars)") return } if len(body.Query) == 0 { writeError(w, http.StatusBadRequest, "query is required") return } // Validate that query is valid JSON object var tmp map[string]interface{} if err := json.Unmarshal(body.Query, &tmp); err != nil { writeError(w, http.StatusBadRequest, "query must be a valid JSON object") return } ss, err := s.store.CreateSavedSearch(r.Context(), sess.UserID, *tenantID, name, []byte(body.Query)) if err != nil { s.logger.Error("saved_searches: create failed", "err", err) writeError(w, http.StatusInternalServerError, "failed to create saved search") return } s.audlog.Log(audit.Entry{ EventType: "saved_search_create", Username: sess.Username, IPAddress: s.remoteIP(r), Detail: fmt.Sprintf("saved search id=%d name=%q", ss.ID, name), Success: true, }) writeJSON(w, http.StatusCreated, ss) } // handleDeleteSavedSearch deletes a saved search by ID. // DELETE /api/searches/saved/{id} func (s *Server) handleDeleteSavedSearch(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) tenantID := tenantFromCtx(r.Context()) if tenantID == nil { writeError(w, http.StatusBadRequest, "tenant context required") return } idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, "invalid id") return } err = s.store.DeleteSavedSearch(r.Context(), id, sess.UserID, *tenantID) if err != nil { if strings.Contains(err.Error(), "not found or not owned") { writeError(w, http.StatusForbidden, "saved search not found or access denied") return } s.logger.Error("saved_searches: delete failed", "err", err) writeError(w, http.StatusInternalServerError, "failed to delete saved search") return } s.audlog.Log(audit.Entry{ EventType: "saved_search_delete", Username: sess.Username, IPAddress: s.remoteIP(r), Detail: fmt.Sprintf("saved search id=%d", id), Success: true, }) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) }