package handler import ( "encoding/json" "errors" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/response" ) // AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in // the router, so every method here can assume an admin caller. type AdminHandler struct{ svc *service.AdminService } func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} } // pageParams pulls (q, limit, offset) from the query string with sane defaults. func pageParams(r *http.Request) (q string, limit, offset int) { q = strings.TrimSpace(r.URL.Query().Get("q")) limit = 25 if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 { limit = v } offset = 0 if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v >= 0 { offset = v } return q, limit, offset } // meta builds the pagination envelope metadata from a total + the request page. func meta(total, limit, offset int) *response.Meta { if limit <= 0 { limit = 25 } pages := (total + limit - 1) / limit return &response.Meta{ Page: offset/limit + 1, Limit: limit, Total: int64(total), TotalPages: pages, } } func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { q, limit, offset := pageParams(r) items, total, err := h.svc.ListUsers(r.Context(), q, limit, offset) if err != nil { response.Error(w, http.StatusInternalServerError, "could not list users") return } response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset)) } func (h *AdminHandler) ListPosts(w http.ResponseWriter, r *http.Request) { q, limit, offset := pageParams(r) items, total, err := h.svc.ListPosts(r.Context(), q, limit, offset) if err != nil { response.Error(w, http.StatusInternalServerError, "could not list posts") return } response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset)) } func (h *AdminHandler) ListCollections(w http.ResponseWriter, r *http.Request) { q, limit, offset := pageParams(r) items, total, err := h.svc.ListCollections(r.Context(), q, limit, offset) if err != nil { response.Error(w, http.StatusInternalServerError, "could not list collections") return } response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset)) } func (h *AdminHandler) ListFeedback(w http.ResponseWriter, r *http.Request) { _, limit, offset := pageParams(r) status := strings.TrimSpace(r.URL.Query().Get("status")) items, total, err := h.svc.ListFeedback(r.Context(), status, limit, offset) if err != nil { response.Error(w, http.StatusInternalServerError, "could not list feedback") return } response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset)) } func (h *AdminHandler) ListReports(w http.ResponseWriter, r *http.Request) { q, limit, offset := pageParams(r) reportType := strings.TrimSpace(r.URL.Query().Get("type")) if reportType == "" { reportType = "post" } if reportType != "post" && reportType != "comment" && reportType != "user" { response.Error(w, http.StatusBadRequest, "type must be post, comment, or user") return } items, total, err := h.svc.ListReports(r.Context(), reportType, q, limit, offset) if err != nil { response.Error(w, http.StatusInternalServerError, "could not list reports") return } response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset)) } // NotifyUser — POST /admin/users/{id}/notify { "title": "...", "body": "..." } func (h *AdminHandler) NotifyUser(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var in struct { Title string `json:"title"` Body string `json:"body"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { response.Error(w, http.StatusBadRequest, "invalid request body") return } in.Title = strings.TrimSpace(in.Title) in.Body = strings.TrimSpace(in.Body) if in.Body == "" { response.Error(w, http.StatusBadRequest, "message body is required") return } if len(in.Title) > 120 || len(in.Body) > 4000 { response.Error(w, http.StatusBadRequest, "message too long") return } err := h.svc.NotifyUser(r.Context(), id, in.Title, in.Body) if errors.Is(err, service.ErrAdminUserNotFound) { response.Error(w, http.StatusNotFound, "user not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "could not send notification") return } response.JSON(w, http.StatusOK, map[string]any{"sent": true}) } // ReplyFeedback — POST /admin/feedback/{id}/reply { "message": "..." } func (h *AdminHandler) ReplyFeedback(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var in struct { Message string `json:"message"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { response.Error(w, http.StatusBadRequest, "invalid request body") return } in.Message = strings.TrimSpace(in.Message) if in.Message == "" { response.Error(w, http.StatusBadRequest, "reply message is required") return } if len(in.Message) > 4000 { response.Error(w, http.StatusBadRequest, "reply too long") return } found, emailed, err := h.svc.ReplyFeedback(r.Context(), id, in.Message) if err != nil { response.Error(w, http.StatusInternalServerError, "could not save reply") return } if !found { response.Error(w, http.StatusNotFound, "feedback not found") return } response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed}) } // ResolveReport — POST /admin/reports/{type}/{id}/resolve func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) { reportType := chi.URLParam(r, "type") id := chi.URLParam(r, "id") if reportType != "post" && reportType != "comment" && reportType != "user" { response.Error(w, http.StatusBadRequest, "type must be post, comment, or user") return } resolved, err := h.svc.ResolveReport(r.Context(), reportType, id) if err != nil { response.Error(w, http.StatusInternalServerError, "could not resolve report") return } // resolved=false means it was already resolved or unknown — still 200 so // the action is idempotent from the client's perspective. response.JSON(w, http.StatusOK, map[string]any{"resolved": resolved}) } // ─── Moderation actions ────────────────────────────────────────────────────── // HidePost — POST /admin/posts/{id}/hide func (h *AdminHandler) HidePost(w http.ResponseWriter, r *http.Request) { h.setPostHidden(w, r, true) } // UnhidePost — DELETE /admin/posts/{id}/hide func (h *AdminHandler) UnhidePost(w http.ResponseWriter, r *http.Request) { h.setPostHidden(w, r, false) } func (h *AdminHandler) setPostHidden(w http.ResponseWriter, r *http.Request, hidden bool) { ok, err := h.svc.SetPostHidden(r.Context(), chi.URLParam(r, "id"), hidden) if err != nil { response.Error(w, http.StatusInternalServerError, "could not update post") return } if !ok { response.Error(w, http.StatusNotFound, "post not found") return } response.JSON(w, http.StatusOK, map[string]any{"hidden": hidden}) } // DeletePost — DELETE /admin/posts/{id} (soft-delete; restorable) func (h *AdminHandler) DeletePost(w http.ResponseWriter, r *http.Request) { ok, err := h.svc.DeletePost(r.Context(), chi.URLParam(r, "id")) if err != nil { response.Error(w, http.StatusInternalServerError, "could not delete post") return } if !ok { response.Error(w, http.StatusNotFound, "post not found") return } response.JSON(w, http.StatusOK, map[string]any{"deleted": true}) } // RestorePost — POST /admin/posts/{id}/restore func (h *AdminHandler) RestorePost(w http.ResponseWriter, r *http.Request) { ok, err := h.svc.RestorePost(r.Context(), chi.URLParam(r, "id")) if err != nil { response.Error(w, http.StatusInternalServerError, "could not restore post") return } if !ok { response.Error(w, http.StatusNotFound, "post not found") return } response.JSON(w, http.StatusOK, map[string]any{"restored": true}) } // DeleteComment — DELETE /admin/comments/{id} (soft-delete; restorable) func (h *AdminHandler) DeleteComment(w http.ResponseWriter, r *http.Request) { ok, err := h.svc.DeleteComment(r.Context(), chi.URLParam(r, "id")) if err != nil { response.Error(w, http.StatusInternalServerError, "could not delete comment") return } if !ok { response.Error(w, http.StatusNotFound, "comment not found") return } response.JSON(w, http.StatusOK, map[string]any{"deleted": true}) } // RestoreComment — POST /admin/comments/{id}/restore func (h *AdminHandler) RestoreComment(w http.ResponseWriter, r *http.Request) { ok, err := h.svc.RestoreComment(r.Context(), chi.URLParam(r, "id")) if err != nil { response.Error(w, http.StatusInternalServerError, "could not restore comment") return } if !ok { response.Error(w, http.StatusNotFound, "comment not found") return } response.JSON(w, http.StatusOK, map[string]any{"restored": true}) } // BanUser — POST /admin/users/{id}/ban func (h *AdminHandler) BanUser(w http.ResponseWriter, r *http.Request) { h.setUserBanned(w, r, true) } // UnbanUser — DELETE /admin/users/{id}/ban func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) { h.setUserBanned(w, r, false) } func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) { ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned) if err != nil { response.Error(w, http.StatusInternalServerError, "could not update account") return } if !ok { response.Error(w, http.StatusNotFound, "user not found") return } response.JSON(w, http.StatusOK, map[string]any{"banned": banned}) }