package handler import ( "encoding/json" "errors" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/rbac" "github.com/prabhupadasays/spsays-bff/internal/repository" "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 // staff guards actions that would otherwise reach other staff members. staff *repository.StaffRepo } func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler { return &AdminHandler{svc: svc, staff: staff} } // 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, } } // Stats — GET /admin/stats → system snapshot for the dashboard home. func (h *AdminHandler) Stats(w http.ResponseWriter, r *http.Request) { snap, err := h.svc.Stats(r.Context()) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load stats") return } response.JSON(w, http.StatusOK, snap) } 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}) } // AcknowledgeFeedback — POST /admin/feedback/{id}/acknowledge func (h *AdminHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) { err := h.svc.AcknowledgeFeedback(r.Context(), chi.URLParam(r, "id")) // Already-acknowledged is a no-op success from the client's perspective. if err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) { response.Error(w, http.StatusInternalServerError, "could not acknowledge feedback") return } response.JSON(w, http.StatusOK, map[string]any{"acknowledged": true}) } // ResolveFeedback — POST /admin/feedback/{id}/resolve func (h *AdminHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) { err := h.svc.ResolveFeedback(r.Context(), chi.URLParam(r, "id")) if err != nil && !errors.Is(err, service.ErrAlreadyResolved) { response.Error(w, http.StatusInternalServerError, "could not resolve feedback") return } response.JSON(w, http.StatusOK, map[string]any{"resolved": true}) } // 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 } var in struct { Note string `json:"note"` } // Body is optional/best-effort; an empty/missing note resolves with no note. _ = json.NewDecoder(r.Body).Decode(&in) resolved, err := h.svc.ResolveReport(r.Context(), reportType, id, strings.TrimSpace(in.Note)) 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) { target := chi.URLParam(r, "id") // A banned account loses panel access, so banning a staff member is a way // to remove them — which only superadmins may do. Without this, an Admin // holding users:ban could ban a Superadmin out of the panel. if _, err := h.staff.Access(r.Context(), target); err == nil { if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) { response.Error(w, http.StatusForbidden, "staff accounts are managed from the Staff page by a superadmin") return } if target == middleware.UserID(r.Context()) { response.Error(w, http.StatusConflict, "you can't ban yourself") return } } ok, err := h.svc.SetUserBanned(r.Context(), target, 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}) } // SetUserLimits — POST /admin/users/{id}/limits with body // { "bookmark_limit": 10|null, "public_collection_limit": …, "private_collection_limit": … }. // Each field is optional: a null (or omitted) value resets that limit to the // plan default; a number caps that resource for this user regardless of plan. // ListDisciples — GET /admin/disciples?q=&limit= — options for the // direct-disciple picker. Read-only; an empty q returns the first page // alphabetically so the picker opens with content. func (h *AdminHandler) ListDisciples(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) items, err := h.svc.SearchDisciples(r.Context(), r.URL.Query().Get("q"), limit) if err != nil { if errors.Is(err, service.ErrDisciplesUnavailable) { response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable") return } response.Error(w, http.StatusInternalServerError, "could not search disciples") return } response.JSON(w, http.StatusOK, items) } // LinkDisciple — POST /admin/users/{id}/disciple {"disciple_id":"…"} — marks the // account as one of Śrīla Prabhupāda's direct disciples. Re-posting with a // different id moves the link. func (h *AdminHandler) LinkDisciple(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var in struct { DiscipleID string `json:"disciple_id"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { response.Error(w, http.StatusBadRequest, "invalid request body") return } if strings.TrimSpace(in.DiscipleID) == "" { response.Error(w, http.StatusBadRequest, "disciple_id is required") return } switch err := h.svc.LinkDisciple(r.Context(), id, in.DiscipleID); { case err == nil: response.JSON(w, http.StatusOK, map[string]any{"linked": true}) case errors.Is(err, service.ErrAdminUserNotFound): response.Error(w, http.StatusNotFound, "user not found") case errors.Is(err, service.ErrDiscipleNotFound): response.Error(w, http.StatusNotFound, "disciple not found") case errors.Is(err, repository.ErrDiscipleClaimed): response.Error(w, http.StatusConflict, "that disciple is already linked to another user") case errors.Is(err, service.ErrDisciplesUnavailable): response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable") default: response.Error(w, http.StatusInternalServerError, "could not link disciple") } } // UnlinkDisciple — DELETE /admin/users/{id}/disciple — removes the link. func (h *AdminHandler) UnlinkDisciple(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") switch err := h.svc.UnlinkDisciple(r.Context(), id); { case err == nil: response.JSON(w, http.StatusOK, map[string]any{"linked": false}) case errors.Is(err, service.ErrAdminUserNotFound): response.Error(w, http.StatusNotFound, "user not found") default: response.Error(w, http.StatusInternalServerError, "could not unlink disciple") } } func (h *AdminHandler) SetUserLimits(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var in struct { BookmarkLimit *int `json:"bookmark_limit"` PublicCollectionLimit *int `json:"public_collection_limit"` PrivateCollectionLimit *int `json:"private_collection_limit"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { response.Error(w, http.StatusBadRequest, "invalid request body") return } const maxLimit = 100000 for _, v := range []*int{in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit} { if v != nil && (*v < 0 || *v > maxLimit) { response.Error(w, http.StatusBadRequest, "each limit must be between 0 and 100000") return } } ok, err := h.svc.SetUserLimits(r.Context(), id, in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit) if err != nil { response.Error(w, http.StatusInternalServerError, "could not update limits") return } if !ok { response.Error(w, http.StatusNotFound, "user not found") return } response.JSON(w, http.StatusOK, map[string]any{"updated": true}) }