From a49bf7591ed8e16e6187c164f379e37ebaa866a1 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Tue, 16 Jun 2026 14:47:45 +0530 Subject: [PATCH] feat: add admin apis --- internal/config/config.go | 6 + internal/handler/admin.go | 292 ++++++++++++++++++ internal/handler/auth.go | 2 + internal/middleware/admin.go | 34 +++ internal/middleware/auth.go | 6 + internal/model/admin.go | 87 ++++++ internal/model/social.go | 4 + internal/model/user.go | 4 + internal/repository/admin_repo.go | 367 +++++++++++++++++++++++ internal/repository/collection_repo.go | 2 +- internal/repository/comment_repo.go | 14 +- internal/repository/post_repo.go | 16 +- internal/repository/social_graph_repo.go | 16 +- internal/repository/user_repo.go | 4 +- internal/server/server.go | 48 ++- internal/service/admin_service.go | 151 ++++++++++ internal/service/auth_service.go | 9 + internal/service/notification_service.go | 77 +++++ migrations/0038_admin_panel.down.sql | 3 + migrations/0038_admin_panel.up.sql | 15 + migrations/0039_user_ban.down.sql | 1 + migrations/0039_user_ban.up.sql | 6 + migrations/0040_soft_delete.down.sql | 4 + migrations/0040_soft_delete.up.sql | 18 ++ 24 files changed, 1164 insertions(+), 22 deletions(-) create mode 100644 internal/handler/admin.go create mode 100644 internal/middleware/admin.go create mode 100644 internal/model/admin.go create mode 100644 internal/repository/admin_repo.go create mode 100644 internal/service/admin_service.go create mode 100644 migrations/0038_admin_panel.down.sql create mode 100644 migrations/0038_admin_panel.up.sql create mode 100644 migrations/0039_user_ban.down.sql create mode 100644 migrations/0039_user_ban.up.sql create mode 100644 migrations/0040_soft_delete.down.sql create mode 100644 migrations/0040_soft_delete.up.sql diff --git a/internal/config/config.go b/internal/config/config.go index f0b2887..15a9428 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..a0c3284 --- /dev/null +++ b/internal/handler/admin.go @@ -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}) +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index ed04762..fe06f01 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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") } diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go new file mode 100644 index 0000000..e73bf4d --- /dev/null +++ b/internal/middleware/admin.go @@ -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) + }) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 6197d7e..66c8786 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -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) diff --git a/internal/model/admin.go b/internal/model/admin.go new file mode 100644 index 0000000..ff2597c --- /dev/null +++ b/internal/model/admin.go @@ -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"` +} diff --git a/internal/model/social.go b/internal/model/social.go index 4e94182..8edbea1 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -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. diff --git a/internal/model/user.go b/internal/model/user.go index d747306..a147e2e 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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 diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go new file mode 100644 index 0000000..4add8de --- /dev/null +++ b/internal/repository/admin_repo.go @@ -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 +} diff --git a/internal/repository/collection_repo.go b/internal/repository/collection_repo.go index 86e2410..e9022f8 100644 --- a/internal/repository/collection_repo.go +++ b/internal/repository/collection_repo.go @@ -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, ) diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index f890b99..1fdedbd 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -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) diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 5ee6fe0..e6ee656 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -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) diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index bf84bae..e5e88b2 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -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) diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 72d1989..ad32ea5 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -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, ) } diff --git a/internal/server/server.go b/internal/server/server.go index 63ff88a..1f1a02f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go new file mode 100644 index 0000000..cc1183c --- /dev/null +++ b/internal/service/admin_service.go @@ -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(`
+

Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏

+ +

Thank you for writing to us. Here's our reply to your feedback:

+ +
{{.Reply}}
+ +

+ Open Prabhupāda Says +

+ +

Your servants,
+ The Prabhupāda Says team

+
`)) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index b51516b..ec15bec 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -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) diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index e52b0a9..5ae480a 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -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(`
Your servants,
The Prabhupāda Says team

`)) + +// 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(`
+

Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏

+ + {{if .Title}}

{{.Title}}

{{end}} + +

{{.Body}}

+ +

+ Open Prabhupāda Says +

+ +

Your servants,
+ The Prabhupāda Says team

+
`)) diff --git a/migrations/0038_admin_panel.down.sql b/migrations/0038_admin_panel.down.sql new file mode 100644 index 0000000..c058f5c --- /dev/null +++ b/migrations/0038_admin_panel.down.sql @@ -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; diff --git a/migrations/0038_admin_panel.up.sql b/migrations/0038_admin_panel.up.sql new file mode 100644 index 0000000..1e66763 --- /dev/null +++ b/migrations/0038_admin_panel.up.sql @@ -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; diff --git a/migrations/0039_user_ban.down.sql b/migrations/0039_user_ban.down.sql new file mode 100644 index 0000000..b699758 --- /dev/null +++ b/migrations/0039_user_ban.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS banned_at; diff --git a/migrations/0039_user_ban.up.sql b/migrations/0039_user_ban.up.sql new file mode 100644 index 0000000..8f38bd4 --- /dev/null +++ b/migrations/0039_user_ban.up.sql @@ -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; diff --git a/migrations/0040_soft_delete.down.sql b/migrations/0040_soft_delete.down.sql new file mode 100644 index 0000000..7ec20fd --- /dev/null +++ b/migrations/0040_soft_delete.down.sql @@ -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; diff --git a/migrations/0040_soft_delete.up.sql b/migrations/0040_soft_delete.up.sql new file mode 100644 index 0000000..82ff229 --- /dev/null +++ b/migrations/0040_soft_delete.up.sql @@ -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;