449 lines
16 KiB
Go
449 lines
16 KiB
Go
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})
|
|
}
|