feat: add admin apis

This commit is contained in:
Krispire Admin
2026-06-16 14:47:45 +05:30
parent aa61f4f0b1
commit a49bf7591e
24 changed files with 1164 additions and 22 deletions
+6
View File
@@ -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"),
+292
View File
@@ -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})
}
+2
View File
@@ -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")
} }
+34
View File
@@ -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)
})
}
}
+6
View File
@@ -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)
+87
View File
@@ -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"`
}
+4
View File
@@ -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.
+4
View File
@@ -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
+367
View File
@@ -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
}
+1 -1
View File
@@ -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,
) )
+7 -7
View File
@@ -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)
+8 -8
View File
@@ -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)
+14 -2
View File
@@ -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)
+2 -2
View File
@@ -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,
) )
} }
+46 -2
View File
@@ -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
+151
View File
@@ -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>`))
+9
View File
@@ -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)
+77
View File
@@ -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>`))
+3
View File
@@ -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;
+15
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS banned_at;
+6
View File
@@ -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;
+4
View File
@@ -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;
+18
View File
@@ -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;