feat: add admin apis
This commit is contained in:
@@ -66,6 +66,11 @@ type Config struct {
|
|||||||
// Frontend
|
// Frontend
|
||||||
FrontendURL string // for CORS + reset/verify links in emails
|
FrontendURL string // for CORS + reset/verify links in emails
|
||||||
|
|
||||||
|
// AdminURL is the admin panel's origin. When set it's added to the CORS
|
||||||
|
// allow-list (the admin app is a separate origin from FrontendURL). Empty
|
||||||
|
// disables admin-origin CORS (only the main frontend is allowed).
|
||||||
|
AdminURL string
|
||||||
|
|
||||||
// RevalidateSecret authenticates BFF→frontend on-demand cache
|
// RevalidateSecret authenticates BFF→frontend on-demand cache
|
||||||
// revalidation pings (POST /api/revalidate). Empty disables the pings.
|
// revalidation pings (POST /api/revalidate). Empty disables the pings.
|
||||||
RevalidateSecret string
|
RevalidateSecret string
|
||||||
@@ -152,6 +157,7 @@ func Load() (*Config, error) {
|
|||||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||||
|
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
|
AdminURL: getEnv("ADMIN_URL", ""),
|
||||||
RevalidateSecret: getEnv("REVALIDATE_SECRET", ""),
|
RevalidateSecret: getEnv("REVALIDATE_SECRET", ""),
|
||||||
|
|
||||||
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
@@ -375,6 +375,8 @@ func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
|
|||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, service.ErrUserNotFound):
|
case errors.Is(err, service.ErrUserNotFound):
|
||||||
response.Error(w, http.StatusNotFound, err.Error())
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, service.ErrAccountSuspended):
|
||||||
|
response.Error(w, http.StatusForbidden, err.Error())
|
||||||
default:
|
default:
|
||||||
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
||||||
|
// RequireAuth (it reads the authenticated user id from context, then looks up
|
||||||
|
// the row to check the role). Non-admins get 403; this keeps the whole
|
||||||
|
// /api/v1/admin/* surface invisible to ordinary accounts.
|
||||||
|
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := UserID(r.Context())
|
||||||
|
if uid == "" {
|
||||||
|
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := users.FindByID(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Role != "admin" {
|
||||||
|
response.Error(w, http.StatusForbidden, "admin access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,12 @@ func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Hand
|
|||||||
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
|
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Suspended accounts are locked out of every protected route, so a
|
||||||
|
// ban takes effect immediately for active sessions too.
|
||||||
|
if u.BannedAt != nil {
|
||||||
|
response.Error(w, http.StatusForbidden, "this account has been suspended")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||||
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Admin DTOs — read-only projections served only to admins via /api/v1/admin/*.
|
||||||
|
// They deliberately differ from the public-facing models: they may expose
|
||||||
|
// fields (email, role, reporter identity) that the normal API hides.
|
||||||
|
|
||||||
|
// AdminUser is a row in the admin users table.
|
||||||
|
type AdminUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
|
BannedAt *time.Time `json:"banned_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPost is a row in the admin posts table.
|
||||||
|
type AdminPost struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
IsHidden bool `json:"is_hidden"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
AuthorHandle string `json:"author_handle"`
|
||||||
|
LikesCount int `json:"likes_count"`
|
||||||
|
CommentsCount int `json:"comments_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCollection is a row in the admin collections table.
|
||||||
|
type AdminCollection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
ItemCount int `json:"item_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
OwnerName string `json:"owner_name"`
|
||||||
|
OwnerHandle string `json:"owner_handle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFeedback is a row in the admin feedback table (includes the reply).
|
||||||
|
type AdminFeedback struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ContactEmail *string `json:"contact_email"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
|
RepliedAt *time.Time `json:"replied_at"`
|
||||||
|
ReplyMessage *string `json:"reply_message"`
|
||||||
|
AuthorHandle *string `json:"author_handle"`
|
||||||
|
AuthorName *string `json:"author_name"`
|
||||||
|
AuthorEmail *string `json:"author_email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminReport is a unified projection over the three report tables
|
||||||
|
// (post_reports, comment_reports, user_reports). Type says which one.
|
||||||
|
type AdminReport struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "post" | "comment" | "user"
|
||||||
|
Category string `json:"category"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
|
|
||||||
|
ReporterHandle *string `json:"reporter_handle"`
|
||||||
|
ReporterName *string `json:"reporter_name"`
|
||||||
|
|
||||||
|
// Target of the report. TargetID is the post/comment/user id; TargetText
|
||||||
|
// is the post caption, comment body, or reported user's handle so the
|
||||||
|
// admin sees what was flagged without a second fetch. TargetAuthorHandle
|
||||||
|
// is the handle of the content's author (nil for user reports).
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
TargetText *string `json:"target_text"`
|
||||||
|
TargetAuthorHandle *string `json:"target_author_handle"`
|
||||||
|
}
|
||||||
@@ -245,6 +245,10 @@ type Notification struct {
|
|||||||
// the client can deep-link to the post for kinds where the actor isn't
|
// the client can deep-link to the post for kinds where the actor isn't
|
||||||
// the post owner (e.g. comment_reply). Nil when there's no post.
|
// the post owner (e.g. comment_reply). Nil when there's no post.
|
||||||
PostAuthorHandle *string `json:"post_author_handle,omitempty"`
|
PostAuthorHandle *string `json:"post_author_handle,omitempty"`
|
||||||
|
|
||||||
|
// Body carries free text for the 'admin_message' kind (an admin → user
|
||||||
|
// direct message). Nil for every event-driven kind.
|
||||||
|
Body *string `json:"body,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushSubscription is one browser/device Web Push endpoint a user granted.
|
// PushSubscription is one browser/device Web Push endpoint a user granted.
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ type User struct {
|
|||||||
TokenVersion int `json:"-"`
|
TokenVersion int `json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// BannedAt suspends the account when set: RequireAuth rejects the session
|
||||||
|
// and LogIn refuses to issue a new one. nil = in good standing.
|
||||||
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminRepo holds the read-mostly queries that back the admin panel. Every
|
||||||
|
// list method returns its page plus the unfiltered total (via COUNT(*) OVER())
|
||||||
|
// so the UI can paginate without a second round-trip. All queries are new —
|
||||||
|
// they read existing tables but don't alter any existing repo behaviour.
|
||||||
|
type AdminRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewAdminRepo(pg *Postgres) *AdminRepo { return &AdminRepo{pg: pg} }
|
||||||
|
|
||||||
|
// clampLimit normalises pagination so a bad client can't ask for everything.
|
||||||
|
func clampLimit(limit int) int {
|
||||||
|
if limit <= 0 {
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns a page of users filtered by an optional query (matched
|
||||||
|
// against name, handle, or email), newest first, with the total count.
|
||||||
|
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, email, handle, name, avatar_url, role, plan, email_verified_at, banned_at, created_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM users
|
||||||
|
WHERE ($1 = '' OR name ILIKE '%'||$1||'%' OR handle ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(email, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminUser{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var u model.AdminUser
|
||||||
|
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
|
||||||
|
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.CreatedAt, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserExists reports whether a user id is real (used before sending a
|
||||||
|
// notification to it).
|
||||||
|
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, id).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin user exists: %w", err)
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPosts returns a page of posts filtered by an optional query (caption,
|
||||||
|
// source title, or author handle), newest first, with the total count.
|
||||||
|
func (r *AdminRepo) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT p.id, p.user_id, p.caption, p.source_title, p.source_url, p.is_hidden, p.deleted_at, p.created_at,
|
||||||
|
u.name, u.handle, p.likes_count,
|
||||||
|
(SELECT COUNT(*) FROM post_comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL) AS comments_count,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE ($1 = '' OR p.caption ILIKE '%'||$1||'%' OR p.source_title ILIKE '%'||$1||'%'
|
||||||
|
OR u.handle ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list posts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminPost{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.AdminPost
|
||||||
|
if err := rows.Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceTitle, &p.SourceURL,
|
||||||
|
&p.IsHidden, &p.DeletedAt, &p.CreatedAt, &p.AuthorName, &p.AuthorHandle,
|
||||||
|
&p.LikesCount, &p.CommentsCount, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin post: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCollections returns a page of collections filtered by an optional query
|
||||||
|
// (title, description, or owner handle), newest first, with the total count.
|
||||||
|
func (r *AdminRepo) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT c.id, c.user_id, c.title, c.description, c.is_public, c.created_at,
|
||||||
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.collection_id = c.id) AS item_count,
|
||||||
|
u.name, u.handle,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM collections c
|
||||||
|
JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE ($1 = '' OR c.title ILIKE '%'||$1||'%' OR COALESCE(c.description, '') ILIKE '%'||$1||'%'
|
||||||
|
OR u.handle ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list collections: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminCollection{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var c model.AdminCollection
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.IsPublic,
|
||||||
|
&c.CreatedAt, &c.ItemCount, &c.OwnerName, &c.OwnerHandle, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin collection: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFeedback returns a page of feedback. status is "all" | "open" |
|
||||||
|
// "resolved". Newest first, with the total count for the chosen filter.
|
||||||
|
func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
if status != "open" && status != "resolved" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
||||||
|
f.resolved_at, f.replied_at, f.reply_message,
|
||||||
|
u.handle, u.name, u.email,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM feedback f
|
||||||
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
WHERE ($1 = 'all'
|
||||||
|
OR ($1 = 'open' AND f.resolved_at IS NULL)
|
||||||
|
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, status, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list feedback: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminFeedback{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var f model.AdminFeedback
|
||||||
|
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
|
&f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
||||||
|
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveReply stores an admin reply on a feedback row, stamps replied_at, and
|
||||||
|
// resolves it (if not already). It returns the recipient's email (the
|
||||||
|
// submitter's account email, else the anonymous contact_email — may be nil if
|
||||||
|
// neither is known) and name so the caller can send the reply email. ok is
|
||||||
|
// false when the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *string, name string, ok bool, err error) {
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE feedback f
|
||||||
|
SET reply_message = $2,
|
||||||
|
replied_at = now(),
|
||||||
|
resolved_at = COALESCE(resolved_at, now())
|
||||||
|
WHERE f.id = $1
|
||||||
|
RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email),
|
||||||
|
COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`,
|
||||||
|
id, message,
|
||||||
|
).Scan(&email, &name)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, fmt.Errorf("admin save feedback reply: %w", err)
|
||||||
|
}
|
||||||
|
return email, name, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReports returns a page of reports of one type ("post" | "comment" |
|
||||||
|
// "user"), unresolved first then newest, filtered by an optional query, with
|
||||||
|
// the total count.
|
||||||
|
func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
var sql string
|
||||||
|
switch reportType {
|
||||||
|
case "post":
|
||||||
|
sql = `
|
||||||
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
|
ru.handle, ru.name,
|
||||||
|
r.post_id, p.caption, pu.handle,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM post_reports r
|
||||||
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
|
LEFT JOIN posts p ON p.id = r.post_id
|
||||||
|
LEFT JOIN users pu ON pu.id = p.user_id
|
||||||
|
WHERE ($1 = '' OR COALESCE(p.caption, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(pu.handle, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
case "comment":
|
||||||
|
sql = `
|
||||||
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
|
ru.handle, ru.name,
|
||||||
|
r.comment_id, c.body, cu.handle,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM comment_reports r
|
||||||
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
|
LEFT JOIN post_comments c ON c.id = r.comment_id
|
||||||
|
LEFT JOIN users cu ON cu.id = c.user_id
|
||||||
|
WHERE ($1 = '' OR COALESCE(c.body, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(cu.handle, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
case "user":
|
||||||
|
sql = `
|
||||||
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
|
ru.handle, ru.name,
|
||||||
|
r.reported_id, tu.handle, tu.handle
|
||||||
|
, COUNT(*) OVER() AS total
|
||||||
|
FROM user_reports r
|
||||||
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
|
LEFT JOIN users tu ON tu.id = r.reported_id
|
||||||
|
WHERE ($1 = '' OR COALESCE(tu.handle, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(tu.name, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
default:
|
||||||
|
return nil, 0, fmt.Errorf("unknown report type %q", reportType)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, sql, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list %s reports: %w", reportType, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminReport{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
rep := model.AdminReport{Type: reportType}
|
||||||
|
if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt,
|
||||||
|
&rep.ReporterHandle, &rep.ReporterName,
|
||||||
|
&rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin report: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, rep)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveReport stamps resolved_at=now() on a still-open report of the given
|
||||||
|
// type. Idempotent and one-way (the WHERE guard). Returns true when this call
|
||||||
|
// is the one that resolved it.
|
||||||
|
func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
||||||
|
var table string
|
||||||
|
switch reportType {
|
||||||
|
case "post":
|
||||||
|
table = "post_reports"
|
||||||
|
case "comment":
|
||||||
|
table = "comment_reports"
|
||||||
|
case "user":
|
||||||
|
table = "user_reports"
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unknown report type %q", reportType)
|
||||||
|
}
|
||||||
|
// table is from a fixed whitelist above — safe to interpolate.
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
fmt.Sprintf(`UPDATE %s SET resolved_at = now() WHERE id = $1 AND resolved_at IS NULL`, table),
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin resolve %s report: %w", reportType, err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SetPostHidden hides/unhides any post (admin override — not owner-scoped).
|
||||||
|
// Returns false if the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`UPDATE posts SET is_hidden = $2, updated_at = now() WHERE id = $1`, postID, hidden)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set post hidden: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPostDeleted soft-deletes or restores a post by stamping/clearing
|
||||||
|
// deleted_at. A soft-deleted post is filtered out of every read path (feeds,
|
||||||
|
// profile, hidden tab, collections, detail) but kept in the table so it can be
|
||||||
|
// restored. Returns false if the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetPostDeleted(ctx context.Context, postID string, deleted bool) (bool, error) {
|
||||||
|
var sql string
|
||||||
|
if deleted {
|
||||||
|
sql = `UPDATE posts SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
||||||
|
} else {
|
||||||
|
sql = `UPDATE posts SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, sql, postID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set post deleted: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommentDeleted soft-deletes or restores a comment (same semantics as
|
||||||
|
// SetPostDeleted). Returns false if the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetCommentDeleted(ctx context.Context, commentID string, deleted bool) (bool, error) {
|
||||||
|
var sql string
|
||||||
|
if deleted {
|
||||||
|
sql = `UPDATE post_comments SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
||||||
|
} else {
|
||||||
|
sql = `UPDATE post_comments SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, sql, commentID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set comment deleted: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserBanned suspends/un-suspends an account. Banning also bumps
|
||||||
|
// token_version, which invalidates the user's existing JWTs so the lock-out is
|
||||||
|
// immediate (RequireAuth additionally rejects banned users). Returns false if
|
||||||
|
// the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
|
||||||
|
var sql string
|
||||||
|
if banned {
|
||||||
|
sql = `UPDATE users SET banned_at = now(), token_version = token_version + 1, updated_at = now() WHERE id = $1`
|
||||||
|
} else {
|
||||||
|
sql = `UPDATE users SET banned_at = NULL, updated_at = now() WHERE id = $1`
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, sql, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set user banned: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
@@ -211,7 +211,7 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
|
|||||||
FROM collection_items ci
|
FROM collection_items ci
|
||||||
JOIN posts p ON p.id = ci.post_id
|
JOIN posts p ON p.id = ci.post_id
|
||||||
JOIN users u ON u.id = p.user_id
|
JOIN users u ON u.id = p.user_id
|
||||||
WHERE ci.collection_id = $1
|
WHERE ci.collection_id = $1 AND p.deleted_at IS NULL
|
||||||
ORDER BY ci.position, ci.added_at`,
|
ORDER BY ci.position, ci.added_at`,
|
||||||
collectionID,
|
collectionID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const commentSelect = `
|
|||||||
u.name, u.handle, u.avatar_url,
|
u.name, u.handle, u.avatar_url,
|
||||||
ru.handle,
|
ru.handle,
|
||||||
(vl.comment_id IS NOT NULL),
|
(vl.comment_id IS NOT NULL),
|
||||||
(SELECT count(*) FROM post_comments rc WHERE rc.parent_id = c.id)`
|
(SELECT count(*) FROM post_comments rc WHERE rc.parent_id = c.id AND rc.deleted_at IS NULL)`
|
||||||
|
|
||||||
// The viewer id always rides in parameter slot $2 across the read queries
|
// The viewer id always rides in parameter slot $2 across the read queries
|
||||||
// below, so the shared viewer-scoped like join can hardcode it.
|
// below, so the shared viewer-scoped like join can hardcode it.
|
||||||
@@ -120,7 +120,7 @@ func (r *CommentRepo) Create(ctx context.Context, postID, userID string, parentI
|
|||||||
|
|
||||||
// Get returns one comment hydrated with author + viewer-like + reply count.
|
// Get returns one comment hydrated with author + viewer-like + reply count.
|
||||||
func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comment, error) {
|
func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comment, error) {
|
||||||
q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.id = $1`
|
q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.id = $1 AND c.deleted_at IS NULL`
|
||||||
c := &model.Comment{}
|
c := &model.Comment{}
|
||||||
err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c)
|
err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -137,7 +137,7 @@ func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comm
|
|||||||
func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||||
var total int
|
var total int
|
||||||
if err := r.pg.Pool.QueryRow(ctx,
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
`SELECT count(*) FROM post_comments WHERE post_id = $1 AND parent_id IS NULL`,
|
`SELECT count(*) FROM post_comments WHERE post_id = $1 AND parent_id IS NULL AND deleted_at IS NULL`,
|
||||||
postID).Scan(&total); err != nil {
|
postID).Scan(&total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("count comments: %w", err)
|
return nil, 0, fmt.Errorf("count comments: %w", err)
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string,
|
|||||||
return []model.Comment{}, 0, nil
|
return []model.Comment{}, 0, nil
|
||||||
}
|
}
|
||||||
q := `SELECT ` + commentSelect + commentFrom + `
|
q := `SELECT ` + commentSelect + commentFrom + `
|
||||||
WHERE c.post_id = $1 AND c.parent_id IS NULL
|
WHERE c.post_id = $1 AND c.parent_id IS NULL AND c.deleted_at IS NULL
|
||||||
ORDER BY (c.pinned_at IS NOT NULL) DESC, c.pinned_at DESC NULLS LAST, c.created_at DESC
|
ORDER BY (c.pinned_at IS NOT NULL) DESC, c.pinned_at DESC NULLS LAST, c.created_at DESC
|
||||||
LIMIT $3 OFFSET $4`
|
LIMIT $3 OFFSET $4`
|
||||||
rows, err := r.pg.Pool.Query(ctx, q, postID, nullUUIDArg(viewerID), limit, offset)
|
rows, err := r.pg.Pool.Query(ctx, q, postID, nullUUIDArg(viewerID), limit, offset)
|
||||||
@@ -160,14 +160,14 @@ func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string,
|
|||||||
func (r *CommentRepo) ListReplies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
func (r *CommentRepo) ListReplies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||||
var total int
|
var total int
|
||||||
if err := r.pg.Pool.QueryRow(ctx,
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
`SELECT count(*) FROM post_comments WHERE parent_id = $1`, parentID).Scan(&total); err != nil {
|
`SELECT count(*) FROM post_comments WHERE parent_id = $1 AND deleted_at IS NULL`, parentID).Scan(&total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("count replies: %w", err)
|
return nil, 0, fmt.Errorf("count replies: %w", err)
|
||||||
}
|
}
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return []model.Comment{}, 0, nil
|
return []model.Comment{}, 0, nil
|
||||||
}
|
}
|
||||||
q := `SELECT ` + commentSelect + commentFrom + `
|
q := `SELECT ` + commentSelect + commentFrom + `
|
||||||
WHERE c.parent_id = $1
|
WHERE c.parent_id = $1 AND c.deleted_at IS NULL
|
||||||
ORDER BY c.created_at ASC
|
ORDER BY c.created_at ASC
|
||||||
LIMIT $3 OFFSET $4`
|
LIMIT $3 OFFSET $4`
|
||||||
rows, err := r.pg.Pool.Query(ctx, q, parentID, nullUUIDArg(viewerID), limit, offset)
|
rows, err := r.pg.Pool.Query(ctx, q, parentID, nullUUIDArg(viewerID), limit, offset)
|
||||||
@@ -211,7 +211,7 @@ func (r *CommentRepo) Hydrate(ctx context.Context, viewerID string, posts []*mod
|
|||||||
// Total comment count per post (top-level + replies).
|
// Total comment count per post (top-level + replies).
|
||||||
counts := make(map[string]int, len(ids))
|
counts := make(map[string]int, len(ids))
|
||||||
crows, err := r.pg.Pool.Query(ctx,
|
crows, err := r.pg.Pool.Query(ctx,
|
||||||
`SELECT post_id, count(*) FROM post_comments WHERE post_id = ANY($1) GROUP BY post_id`,
|
`SELECT post_id, count(*) FROM post_comments WHERE post_id = ANY($1) AND deleted_at IS NULL GROUP BY post_id`,
|
||||||
ids)
|
ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hydrate comment counts: %w", err)
|
return fmt.Errorf("hydrate comment counts: %w", err)
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
|
|||||||
// FindByID returns one post with its images and author info (plus original
|
// FindByID returns one post with its images and author info (plus original
|
||||||
// author info if this row is a reshare).
|
// author info if this row is a reshare).
|
||||||
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
||||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1 AND p.deleted_at IS NULL`
|
||||||
p := &model.Post{}
|
p := &model.Post{}
|
||||||
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -188,14 +188,14 @@ func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, li
|
|||||||
// user is not the author, so there is no owner exception here).
|
// user is not the author, so there is no owner exception here).
|
||||||
var total int
|
var total int
|
||||||
if err := r.pg.Pool.QueryRow(ctx,
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1 AND p.is_hidden = FALSE`,
|
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`,
|
||||||
userID,
|
userID,
|
||||||
).Scan(&total); err != nil {
|
).Scan(&total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
|
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
|
||||||
}
|
}
|
||||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
JOIN post_mentions pm ON pm.post_id = p.id
|
JOIN post_mentions pm ON pm.post_id = p.id
|
||||||
WHERE pm.user_id = $1 AND p.is_hidden = FALSE
|
WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||||
ORDER BY p.created_at DESC, p.id
|
ORDER BY p.created_at DESC, p.id
|
||||||
LIMIT $2 OFFSET $3`
|
LIMIT $2 OFFSET $3`
|
||||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
@@ -214,7 +214,7 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE
|
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||||
ORDER BY p.created_at DESC, p.id
|
ORDER BY p.created_at DESC, p.id
|
||||||
LIMIT $2 OFFSET $3`
|
LIMIT $2 OFFSET $3`
|
||||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
@@ -228,7 +228,7 @@ func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, o
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
WHERE p.user_id = $1 AND p.is_hidden = TRUE
|
WHERE p.user_id = $1 AND p.is_hidden = TRUE AND p.deleted_at IS NULL
|
||||||
ORDER BY p.created_at DESC, p.id
|
ORDER BY p.created_at DESC, p.id
|
||||||
LIMIT $2 OFFSET $3`
|
LIMIT $2 OFFSET $3`
|
||||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
@@ -260,7 +260,7 @@ func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, of
|
|||||||
}
|
}
|
||||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
WHERE p.user_id <> $1
|
WHERE p.user_id <> $1
|
||||||
AND p.is_hidden = FALSE
|
AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||||
AND p.id <> ALL($4)
|
AND p.id <> ALL($4)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM user_blocks b
|
SELECT 1 FROM user_blocks b
|
||||||
@@ -383,7 +383,7 @@ func (r *PostRepo) SetHidden(ctx context.Context, postID, userID string, hidden
|
|||||||
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.pg.Pool.QueryRow(ctx,
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = FALSE`, userID,
|
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = FALSE AND deleted_at IS NULL`, userID,
|
||||||
).Scan(&n)
|
).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count posts: %w", err)
|
return 0, fmt.Errorf("count posts: %w", err)
|
||||||
@@ -396,7 +396,7 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error)
|
|||||||
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
|
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.pg.Pool.QueryRow(ctx,
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE`, userID,
|
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE AND deleted_at IS NULL`, userID,
|
||||||
).Scan(&n)
|
).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count hidden posts: %w", err)
|
return 0, fmt.Errorf("count hidden posts: %w", err)
|
||||||
|
|||||||
@@ -315,6 +315,18 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateAdminMessage persists an admin → user direct message. Unlike the
|
||||||
|
// event-driven kinds it has no actor and carries free text in `body`.
|
||||||
|
func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body string) error {
|
||||||
|
if _, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO notifications (user_id, kind, body) VALUES ($1, 'admin_message', $2)`,
|
||||||
|
userID, body,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("create admin message notification: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListByUser returns a recipient's notifications, newest first, with the
|
// ListByUser returns a recipient's notifications, newest first, with the
|
||||||
// actor's display fields joined in for rendering (nil for system
|
// actor's display fields joined in for rendering (nil for system
|
||||||
// notifications or since-deleted actors). Kinds the user has turned off for
|
// notifications or since-deleted actors). Kinds the user has turned off for
|
||||||
@@ -325,7 +337,7 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
|||||||
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
||||||
n.created_at, n.read_at,
|
n.created_at, n.read_at,
|
||||||
u.name, u.handle, u.avatar_url,
|
u.name, u.handle, u.avatar_url,
|
||||||
pu.handle
|
pu.handle, n.body
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN users u ON u.id = n.actor_id
|
LEFT JOIN users u ON u.id = n.actor_id
|
||||||
LEFT JOIN posts p ON p.id = n.post_id
|
LEFT JOIN posts p ON p.id = n.post_id
|
||||||
@@ -348,7 +360,7 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
|||||||
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
||||||
&n.CreatedAt, &n.ReadAt,
|
&n.CreatedAt, &n.ReadAt,
|
||||||
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
|
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
|
||||||
&n.PostAuthorHandle); err != nil {
|
&n.PostAuthorHandle, &n.Body); err != nil {
|
||||||
return nil, fmt.Errorf("scan notification: %w", err)
|
return nil, fmt.Errorf("scan notification: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, n)
|
out = append(out, n)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func NewUserRepo(pg *Postgres) *UserRepo {
|
|||||||
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
email_verified_at, token_version, created_at, updated_at, country,
|
email_verified_at, token_version, created_at, updated_at, country,
|
||||||
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
||||||
preferred_chanting_time, peak_active_time`
|
preferred_chanting_time, peak_active_time, banned_at`
|
||||||
|
|
||||||
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
||||||
// scanUser works for single-row and iterated reads alike.
|
// scanUser works for single-row and iterated reads alike.
|
||||||
@@ -50,7 +50,7 @@ func scanUser(row rowScanner, u *model.User) error {
|
|||||||
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||||
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
||||||
&u.PreferredChantingTime, &u.PeakActiveTime,
|
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type Server struct {
|
|||||||
reminderH *handler.ReminderHandler
|
reminderH *handler.ReminderHandler
|
||||||
feedbackH *handler.FeedbackHandler
|
feedbackH *handler.FeedbackHandler
|
||||||
statsH *handler.StatsHandler
|
statsH *handler.StatsHandler
|
||||||
|
adminH *handler.AdminHandler
|
||||||
|
|
||||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||||
authIssuer *auth.Issuer
|
authIssuer *auth.Issuer
|
||||||
@@ -284,6 +285,13 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||||
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
||||||
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
|
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
|
||||||
|
|
||||||
|
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
||||||
|
// Reuses the notification service (delivery) and mailer (feedback reply).
|
||||||
|
// Gated by RequireAuth + RequireAdmin in the router.
|
||||||
|
adminRepo := repository.NewAdminRepo(s.pg)
|
||||||
|
adminSvc := service.NewAdminService(adminRepo, notifSvc, mail, logger, cfg.FrontendURL)
|
||||||
|
s.adminH = handler.NewAdminHandler(adminSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||||
@@ -404,9 +412,17 @@ func (s *Server) setupRouter() {
|
|||||||
r.Use(chiMiddleware.Recoverer)
|
r.Use(chiMiddleware.Recoverer)
|
||||||
r.Use(chiMiddleware.Compress(5))
|
r.Use(chiMiddleware.Compress(5))
|
||||||
|
|
||||||
// CORS
|
// CORS — the main frontend plus, when configured, the admin panel (a
|
||||||
|
// separate origin). Empty origins are filtered so the list never widens
|
||||||
|
// unintentionally.
|
||||||
|
allowedOrigins := []string{}
|
||||||
|
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
|
||||||
|
if o != "" {
|
||||||
|
allowedOrigins = append(allowedOrigins, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{s.cfg.FrontendURL},
|
AllowedOrigins: allowedOrigins,
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
||||||
ExposedHeaders: []string{"X-Request-ID"},
|
ExposedHeaders: []string{"X-Request-ID"},
|
||||||
@@ -961,6 +977,34 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/timezone", s.reminderH.SetTimezone)
|
r.Post("/timezone", s.reminderH.SetTimezone)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Admin panel --- every route gated by RequireAuth + RequireAdmin
|
||||||
|
// (role == "admin"), so the whole surface is invisible to ordinary
|
||||||
|
// accounts. Additive: no existing route or payload changes.
|
||||||
|
r.Route("/admin", func(r chi.Router) {
|
||||||
|
if s.adminH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Use(middleware.RequireAdmin(s.userRepo))
|
||||||
|
r.Get("/users", s.adminH.ListUsers)
|
||||||
|
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||||
|
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||||
|
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||||
|
r.Get("/posts", s.adminH.ListPosts)
|
||||||
|
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||||
|
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||||
|
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
||||||
|
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||||
|
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||||
|
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||||
|
r.Get("/collections", s.adminH.ListCollections)
|
||||||
|
r.Get("/feedback", s.adminH.ListFeedback)
|
||||||
|
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||||
|
r.Get("/reports", s.adminH.ListReports)
|
||||||
|
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router = r
|
s.router = r
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrAdminUserNotFound is returned by NotifyUser when the target id is unknown.
|
||||||
|
var ErrAdminUserNotFound = errors.New("user not found")
|
||||||
|
|
||||||
|
// AdminService backs the admin panel: read-mostly listing plus two actions
|
||||||
|
// (notify a user, reply to feedback) and report resolution. It reuses the
|
||||||
|
// existing NotificationService for delivery and the shared mailer for the
|
||||||
|
// feedback-reply email — no new infrastructure.
|
||||||
|
type AdminService struct {
|
||||||
|
repo *repository.AdminRepo
|
||||||
|
notif *NotificationService
|
||||||
|
mail mailer.Mailer
|
||||||
|
logger *zap.Logger
|
||||||
|
frontendURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||||
|
return &AdminService{repo: repo, notif: notif, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||||
|
return s.repo.ListUsers(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||||
|
return s.repo.ListPosts(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
|
||||||
|
return s.repo.ListCollections(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
|
||||||
|
return s.repo.ListFeedback(ctx, status, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
|
||||||
|
return s.repo.ListReports(ctx, reportType, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyUser sends a free-text admin message to one user (in-app + push +
|
||||||
|
// email). Returns ErrAdminUserNotFound if the id doesn't exist.
|
||||||
|
func (s *AdminService) NotifyUser(ctx context.Context, userID, title, body string) error {
|
||||||
|
exists, err := s.repo.UserExists(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return s.notif.NotifyAdminMessage(ctx, userID, title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplyFeedback stores the reply (resolving the row) and emails it to the
|
||||||
|
// submitter when an address is known. Returns found=false if the id is
|
||||||
|
// unknown, and emailed=true when a reply email was actually sent.
|
||||||
|
func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (found, emailed bool, err error) {
|
||||||
|
email, name, ok, err := s.repo.SaveReply(ctx, id, message)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return ok, false, err
|
||||||
|
}
|
||||||
|
if email == nil || strings.TrimSpace(*email) == "" {
|
||||||
|
// Reply stored, but the submitter was anonymous with no contact email.
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": name,
|
||||||
|
"Reply": message,
|
||||||
|
"URL": s.frontendURL,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
if err := s.mail.Send(strings.TrimSpace(*email), "Re: your feedback — Prabhupāda Says", buf.String()); err != nil {
|
||||||
|
s.logger.Warn("feedback reply email send failed", zap.Error(err))
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
return true, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveReport stamps a report resolved. found=true when this call resolved a
|
||||||
|
// previously-open report (idempotent: a no-op on an already-resolved/unknown id
|
||||||
|
// returns false).
|
||||||
|
func (s *AdminService) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
||||||
|
return s.repo.ResolveReport(ctx, reportType, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SetPostHidden hides or unhides any post (found=false if unknown id).
|
||||||
|
func (s *AdminService) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
|
||||||
|
return s.repo.SetPostHidden(ctx, postID, hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePost soft-deletes a post (hidden from all reads, restorable).
|
||||||
|
func (s *AdminService) DeletePost(ctx context.Context, postID string) (bool, error) {
|
||||||
|
return s.repo.SetPostDeleted(ctx, postID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestorePost reverses a soft-delete.
|
||||||
|
func (s *AdminService) RestorePost(ctx context.Context, postID string) (bool, error) {
|
||||||
|
return s.repo.SetPostDeleted(ctx, postID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComment soft-deletes a comment (restorable).
|
||||||
|
func (s *AdminService) DeleteComment(ctx context.Context, commentID string) (bool, error) {
|
||||||
|
return s.repo.SetCommentDeleted(ctx, commentID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreComment reverses a soft-delete.
|
||||||
|
func (s *AdminService) RestoreComment(ctx context.Context, commentID string) (bool, error) {
|
||||||
|
return s.repo.SetCommentDeleted(ctx, commentID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserBanned suspends or restores an account.
|
||||||
|
func (s *AdminService) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
|
||||||
|
return s.repo.SetUserBanned(ctx, userID, banned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||||
|
// html/template, so admin-entered text can't inject markup.
|
||||||
|
var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
||||||
|
|
||||||
|
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
@@ -35,6 +35,7 @@ var (
|
|||||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||||
ErrHandleTaken = errors.New("handle already in use")
|
ErrHandleTaken = errors.New("handle already in use")
|
||||||
|
ErrAccountSuspended = errors.New("this account has been suspended")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthService bundles every flow that mutates auth state.
|
// AuthService bundles every flow that mutates auth state.
|
||||||
@@ -193,6 +194,11 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*Login
|
|||||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
// Suspended accounts can't sign in (checked after the password so we don't
|
||||||
|
// reveal ban status to someone who doesn't know the password).
|
||||||
|
if u.BannedAt != nil {
|
||||||
|
return nil, ErrAccountSuspended
|
||||||
|
}
|
||||||
|
|
||||||
// OTP gate — unverified email AND account is older than the grace
|
// OTP gate — unverified email AND account is older than the grace
|
||||||
// window. Mint an OTP and signal the caller to collect it. We do NOT
|
// window. Mint an OTP and signal the caller to collect it. We do NOT
|
||||||
@@ -233,6 +239,9 @@ func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp s
|
|||||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
if u.BannedAt != nil {
|
||||||
|
return nil, ErrAccountSuspended
|
||||||
|
}
|
||||||
|
|
||||||
otpHash := hashToken(otp)
|
otpHash := hashToken(otp)
|
||||||
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
|
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
|
||||||
|
|||||||
@@ -612,6 +612,66 @@ func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotifyAdminMessage delivers a free-text message from an admin to one user:
|
||||||
|
// a persisted in-app notification (kind 'admin_message') plus best-effort
|
||||||
|
// realtime, Web Push, and email. Admin messages bypass the per-kind opt-out
|
||||||
|
// matrix (there's no category for them) — they're deliberate, low-volume, and
|
||||||
|
// operationally important. Returns an error only if the row couldn't be
|
||||||
|
// persisted; the three delivery channels are best-effort.
|
||||||
|
func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientID, title, body string) error {
|
||||||
|
if err := s.notifs.CreateAdminMessage(ctx, recipientID, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := s.frontendURL + "/notifications"
|
||||||
|
|
||||||
|
if s.rt != nil {
|
||||||
|
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", map[string]any{
|
||||||
|
"kind": "admin_message",
|
||||||
|
"body": body,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("realtime admin message publish failed",
|
||||||
|
zap.String("recipient", recipientID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.push != nil {
|
||||||
|
pushTitle := strings.TrimSpace(title)
|
||||||
|
if pushTitle == "" {
|
||||||
|
pushTitle = "Prabhupāda Says"
|
||||||
|
}
|
||||||
|
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||||
|
Title: pushTitle,
|
||||||
|
Body: body,
|
||||||
|
URL: url,
|
||||||
|
Icon: s.iconURL(),
|
||||||
|
Tag: "admin_message",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email — best-effort, only when the recipient has a verified-or-not address.
|
||||||
|
recipient, _ := s.users.FindByID(ctx, recipientID)
|
||||||
|
if recipient != nil && recipient.Email != nil && strings.TrimSpace(*recipient.Email) != "" {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": recipient.Name,
|
||||||
|
"Title": strings.TrimSpace(title),
|
||||||
|
"Body": body,
|
||||||
|
"URL": url,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
subject := strings.TrimSpace(title)
|
||||||
|
if subject == "" {
|
||||||
|
subject = "A message from Prabhupāda Says"
|
||||||
|
}
|
||||||
|
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
|
||||||
|
s.logger.Warn("admin message email send failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// iconURL is the absolute URL of the notification icon (the app's square
|
// iconURL is the absolute URL of the notification icon (the app's square
|
||||||
// icon), so it renders regardless of the client's base path.
|
// icon), so it renders regardless of the client's base path.
|
||||||
func (s *NotificationService) iconURL() string {
|
func (s *NotificationService) iconURL() string {
|
||||||
@@ -715,3 +775,20 @@ var likeEmailTpl = template.Must(template.New("postLike").Parse(`<div style="fon
|
|||||||
<p style="margin-top:1em">Your servants,<br>
|
<p style="margin-top:1em">Your servants,<br>
|
||||||
<strong>The Prabhupāda Says team</strong></p>
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
</div>`))
|
</div>`))
|
||||||
|
|
||||||
|
// adminMessageEmailTpl renders an admin → user direct message. Body is
|
||||||
|
// auto-escaped by html/template, so admin-entered text can't inject markup.
|
||||||
|
var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
||||||
|
|
||||||
|
<p style="white-space:pre-line">{{.Body}}</p>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE feedback DROP COLUMN IF EXISTS reply_message;
|
||||||
|
ALTER TABLE feedback DROP COLUMN IF EXISTS replied_at;
|
||||||
|
ALTER TABLE notifications DROP COLUMN IF EXISTS body;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Admin panel support. All additive — existing columns/queries are untouched,
|
||||||
|
-- so the public API and the existing frontend keep working unchanged.
|
||||||
|
|
||||||
|
-- Feedback replies: an admin can write a reply that gets emailed to the
|
||||||
|
-- submitter. reply_message holds the text, replied_at stamps when it was sent
|
||||||
|
-- (one-way, like resolved_at). Replying also resolves the row.
|
||||||
|
ALTER TABLE feedback
|
||||||
|
ADD COLUMN IF NOT EXISTS reply_message TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS replied_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Admin → user direct messages reuse the notifications feed. Event-driven
|
||||||
|
-- notifications carry no free text, so add an optional body column that only
|
||||||
|
-- the 'admin_message' kind populates (all existing kinds leave it NULL).
|
||||||
|
ALTER TABLE notifications
|
||||||
|
ADD COLUMN IF NOT EXISTS body TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS banned_at;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Account suspension (ban) support for the admin panel. Additive: the column
|
||||||
|
-- is nullable and defaults to NULL (not banned), so existing rows and queries
|
||||||
|
-- are unaffected. Enforcement: RequireAuth rejects a user whose banned_at is
|
||||||
|
-- set, and LogIn refuses to issue a new session for one.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS banned_at TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS posts_not_deleted_idx;
|
||||||
|
DROP INDEX IF EXISTS post_comments_not_deleted_idx;
|
||||||
|
ALTER TABLE posts DROP COLUMN IF EXISTS deleted_at;
|
||||||
|
ALTER TABLE post_comments DROP COLUMN IF EXISTS deleted_at;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Soft-delete (admin moderation) for posts and comments. Additive: the column
|
||||||
|
-- is nullable and defaults to NULL, and every read query now filters
|
||||||
|
-- `deleted_at IS NULL` — a no-op for existing rows (all NULL), so nothing in
|
||||||
|
-- the live app changes until an admin actually deletes something. A soft-
|
||||||
|
-- deleted row disappears from all reads but stays in the table, recoverable
|
||||||
|
-- via the admin "restore" action.
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE post_comments
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Partial indexes keep the hot "not deleted" reads fast (they match the
|
||||||
|
-- deleted_at IS NULL predicate now on every posts/comments query).
|
||||||
|
CREATE INDEX IF NOT EXISTS posts_not_deleted_idx
|
||||||
|
ON posts (created_at DESC) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS post_comments_not_deleted_idx
|
||||||
|
ON post_comments (post_id) WHERE deleted_at IS NULL;
|
||||||
Reference in New Issue
Block a user