feat: add admin apis
This commit is contained in:
@@ -66,6 +66,11 @@ type Config struct {
|
||||
// Frontend
|
||||
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
|
||||
// revalidation pings (POST /api/revalidate). Empty disables the pings.
|
||||
RevalidateSecret string
|
||||
@@ -152,6 +157,7 @@ func Load() (*Config, error) {
|
||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
AdminURL: getEnv("ADMIN_URL", ""),
|
||||
RevalidateSecret: getEnv("REVALIDATE_SECRET", ""),
|
||||
|
||||
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())
|
||||
case errors.Is(err, service.ErrUserNotFound):
|
||||
response.Error(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrAccountSuspended):
|
||||
response.Error(w, http.StatusForbidden, err.Error())
|
||||
default:
|
||||
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")
|
||||
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(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 post owner (e.g. comment_reply). Nil when there's no post.
|
||||
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.
|
||||
|
||||
@@ -71,6 +71,10 @@ type User struct {
|
||||
TokenVersion int `json:"-"`
|
||||
CreatedAt time.Time `json:"created_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
|
||||
|
||||
@@ -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
|
||||
JOIN posts p ON p.id = ci.post_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`,
|
||||
collectionID,
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ const commentSelect = `
|
||||
u.name, u.handle, u.avatar_url,
|
||||
ru.handle,
|
||||
(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
|
||||
// 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.
|
||||
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{}
|
||||
err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c)
|
||||
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) {
|
||||
var total int
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
LIMIT $3 OFFSET $4`
|
||||
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) {
|
||||
var total int
|
||||
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)
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.Comment{}, 0, nil
|
||||
}
|
||||
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
|
||||
LIMIT $3 OFFSET $4`
|
||||
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).
|
||||
counts := make(map[string]int, len(ids))
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
// author info if this row is a reshare).
|
||||
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{}
|
||||
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
||||
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).
|
||||
var total int
|
||||
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,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
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
|
||||
LIMIT $2 OFFSET $3`
|
||||
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
|
||||
}
|
||||
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
|
||||
LIMIT $2 OFFSET $3`
|
||||
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
|
||||
}
|
||||
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
|
||||
LIMIT $2 OFFSET $3`
|
||||
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 + `
|
||||
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 NOT EXISTS (
|
||||
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) {
|
||||
var n int
|
||||
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)
|
||||
if err != nil {
|
||||
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) {
|
||||
var n int
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// actor's display fields joined in for rendering (nil for system
|
||||
// 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,
|
||||
n.created_at, n.read_at,
|
||||
u.name, u.handle, u.avatar_url,
|
||||
pu.handle
|
||||
pu.handle, n.body
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_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,
|
||||
&n.CreatedAt, &n.ReadAt,
|
||||
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
|
||||
&n.PostAuthorHandle); err != nil {
|
||||
&n.PostAuthorHandle, &n.Body); err != nil {
|
||||
return nil, fmt.Errorf("scan notification: %w", err)
|
||||
}
|
||||
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,
|
||||
email_verified_at, token_version, created_at, updated_at, country,
|
||||
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
|
||||
// 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.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||
&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
|
||||
feedbackH *handler.FeedbackHandler
|
||||
statsH *handler.StatsHandler
|
||||
adminH *handler.AdminHandler
|
||||
|
||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||
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)
|
||||
s.feedbackH = handler.NewFeedbackHandler(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.
|
||||
@@ -404,9 +412,17 @@ func (s *Server) setupRouter() {
|
||||
r.Use(chiMiddleware.Recoverer)
|
||||
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{
|
||||
AllowedOrigins: []string{s.cfg.FrontendURL},
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
||||
ExposedHeaders: []string{"X-Request-ID"},
|
||||
@@ -961,6 +977,34 @@ func (s *Server) setupRouter() {
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
ErrHandleTaken = errors.New("handle already in use")
|
||||
ErrAccountSuspended = errors.New("this account has been suspended")
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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
|
||||
// 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 {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if u.BannedAt != nil {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
otpHash := hashToken(otp)
|
||||
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
|
||||
// icon), so it renders regardless of the client's base path.
|
||||
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>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</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