From 6ebaa3615872bead9fff8054fa6e01e341f677c1 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sat, 13 Jun 2026 01:29:55 +0530 Subject: [PATCH] feat: add comments --- internal/handler/comment.go | 156 ++++++++ internal/handler/social.go | 13 +- internal/model/social.go | 41 ++ internal/repository/comment_repo.go | 351 ++++++++++++++++++ internal/repository/social_graph_repo.go | 38 +- internal/server/server.go | 48 ++- internal/service/comment_service.go | 183 +++++++++ internal/service/notification_service.go | 57 ++- internal/service/social_service.go | 26 +- migrations/0036_create_post_comments.down.sql | 3 + migrations/0036_create_post_comments.up.sql | 62 ++++ 11 files changed, 968 insertions(+), 10 deletions(-) create mode 100644 internal/handler/comment.go create mode 100644 internal/repository/comment_repo.go create mode 100644 internal/service/comment_service.go create mode 100644 migrations/0036_create_post_comments.down.sql create mode 100644 migrations/0036_create_post_comments.up.sql diff --git a/internal/handler/comment.go b/internal/handler/comment.go new file mode 100644 index 0000000..4ec6dac --- /dev/null +++ b/internal/handler/comment.go @@ -0,0 +1,156 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/middleware" + "github.com/prabhupadasays/spsays-bff/internal/service" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +// CommentHandler serves the post-comment endpoints: listing top-level comments +// + a thread's replies (public), and create / like / pin / delete / report +// (auth-required). +type CommentHandler struct { + svc *service.CommentService +} + +func NewCommentHandler(svc *service.CommentService) *CommentHandler { + return &CommentHandler{svc: svc} +} + +type createCommentReq struct { + Body string `json:"body"` + // ParentID nil → top-level comment; otherwise the comment being replied + // to (the service flattens replies onto the thread root). + ParentID *string `json:"parent_id"` +} + +type commentLikeResp struct { + LikesCount int `json:"likes_count"` +} + +// List — GET /posts/{id}/comments?limit=&offset=. Top-level comments, pinned +// first then newest. Public (OptionalAuth populates viewer_has_liked). +func (h *CommentHandler) List(w http.ResponseWriter, r *http.Request) { + postID := chi.URLParam(r, "id") + viewerID := middleware.UserID(r.Context()) + limit, offset := parseLimitOffset(r) + items, total, err := h.svc.List(r.Context(), postID, viewerID, limit, offset) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset)) +} + +// Replies — GET /comments/{id}/replies?limit=&offset=. A thread's replies, +// oldest first. Public (OptionalAuth). +func (h *CommentHandler) Replies(w http.ResponseWriter, r *http.Request) { + commentID := chi.URLParam(r, "id") + viewerID := middleware.UserID(r.Context()) + limit, offset := parseLimitOffset(r) + items, total, err := h.svc.Replies(r.Context(), commentID, viewerID, limit, offset) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset)) +} + +// Create — POST /posts/{id}/comments. Body: {body, parent_id?}. RequireAuth. +func (h *CommentHandler) Create(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + postID := chi.URLParam(r, "id") + var req createCommentReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid request body") + return + } + // Treat an empty parent_id as a top-level comment, not a (missing) reply. + if req.ParentID != nil && *req.ParentID == "" { + req.ParentID = nil + } + c, err := h.svc.Add(r.Context(), uid, postID, req.ParentID, req.Body) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusCreated, c) +} + +// Like — POST /comments/{id}/like. Idempotent. RequireAuth. +func (h *CommentHandler) Like(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + count, err := h.svc.Like(r.Context(), uid, id) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count}) +} + +// Unlike — DELETE /comments/{id}/like. Idempotent. RequireAuth. +func (h *CommentHandler) Unlike(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + count, err := h.svc.Unlike(r.Context(), uid, id) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count}) +} + +// Pin — POST /comments/{id}/pin. Post author only; top-level only. RequireAuth. +func (h *CommentHandler) Pin(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + if err := h.svc.Pin(r.Context(), uid, id); err != nil { + mapSocialErr(w, err) + return + } + response.NoContent(w) +} + +// Unpin — DELETE /comments/{id}/pin. Post author only. RequireAuth. +func (h *CommentHandler) Unpin(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + if err := h.svc.Unpin(r.Context(), uid, id); err != nil { + mapSocialErr(w, err) + return + } + response.NoContent(w) +} + +// Delete — DELETE /comments/{id}. Comment author or post author. RequireAuth. +// For a thread root this removes the whole thread. +func (h *CommentHandler) Delete(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + if err := h.svc.Delete(r.Context(), uid, id); err != nil { + mapSocialErr(w, err) + return + } + response.NoContent(w) +} + +// Report — POST /comments/{id}/report. Body: {category, reason?}. RequireAuth. +func (h *CommentHandler) Report(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + var req reportPostReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if err := h.svc.Report(r.Context(), uid, id, req.Category, req.Reason); err != nil { + mapSocialErr(w, err) + return + } + response.NoContent(w) +} diff --git a/internal/handler/social.go b/internal/handler/social.go index c50ca93..92c26f0 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -545,10 +545,11 @@ type ProfileHandler struct { graph *service.SocialGraphService likes *repository.LikeRepo mentions *repository.MentionRepo + comments *repository.CommentRepo } -func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo, mentions *repository.MentionRepo) *ProfileHandler { - return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes, mentions: mentions} +func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo) *ProfileHandler { + return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes, mentions: mentions, comments: comments} } func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { @@ -700,7 +701,10 @@ func (h *ProfileHandler) hydrateFeed(r *http.Request, posts []model.Post) error if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil { return err } - return h.mentions.HydrateCounts(r.Context(), ptrs) + if err := h.mentions.HydrateCounts(r.Context(), ptrs); err != nil { + return err + } + return h.comments.HydratePinned(r.Context(), viewerID, ptrs) } // followListResp is the payload for the followers / following list endpoints: @@ -755,10 +759,13 @@ func mapSocialErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, repository.ErrUserNotFound), errors.Is(err, repository.ErrPostNotFound), + errors.Is(err, repository.ErrCommentNotFound), errors.Is(err, repository.ErrBookmarkNotFound), errors.Is(err, repository.ErrCollectionNotFound), errors.Is(err, service.ErrNotFound): response.Error(w, http.StatusNotFound, "not found") + case errors.Is(err, repository.ErrDuplicateComment): + response.Error(w, http.StatusConflict, "you've already posted this comment") case errors.Is(err, service.ErrForbidden): response.Error(w, http.StatusForbidden, "not allowed") case errors.Is(err, service.ErrBlocked): diff --git a/internal/model/social.go b/internal/model/social.go index 1a1c552..464e25b 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -50,6 +50,42 @@ type Post struct { // MentionsCount is how many users are tagged on this post (drives the // post-card mentions icon). Hydrated by the mention layer. MentionsCount int `json:"mentions_count"` + + // PinnedComment is the post's pinned comment (if any), hydrated by the + // comment layer so the card footer can show it without a per-card fetch. + // Nil when nothing is pinned. + PinnedComment *Comment `json:"pinned_comment,omitempty"` +} + +// Comment is one comment (or reply) on a post. Threading is one level deep: +// ParentID is nil for a top-level comment and otherwise points at the thread +// root. ReplyToHandle, when set, is the @handle the reply was addressed to +// (replies stay flat under the root; this just preserves who was answered). +type Comment struct { + ID string `json:"id"` + PostID string `json:"post_id"` + UserID string `json:"user_id"` + ParentID *string `json:"parent_id,omitempty"` + Body string `json:"body"` + LikesCount int `json:"likes_count"` + PinnedAt *time.Time `json:"pinned_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Author info — populated by repo joins. + AuthorName string `json:"author_name"` + AuthorHandle string `json:"author_handle"` + AuthorAvatarURL *string `json:"author_avatar_url,omitempty"` + + // ReplyToHandle is the handle this reply was addressed to (nil on + // top-level comments), for the "Replying to @handle" prefix. + ReplyToHandle *string `json:"reply_to_handle,omitempty"` + + // ViewerHasLiked reflects the calling user's like membership; false for + // anonymous viewers. ReplyCount is the number of replies under a + // top-level comment (0 on replies themselves). + ViewerHasLiked bool `json:"viewer_has_liked"` + ReplyCount int `json:"reply_count"` } // PostImage represents one attached image on a post. @@ -188,6 +224,11 @@ type Notification struct { ActorName *string `json:"actor_name,omitempty"` ActorHandle *string `json:"actor_handle,omitempty"` ActorAvatar *string `json:"actor_avatar_url,omitempty"` + + // PostAuthorHandle is the handle of the author of PostID (when set), so + // 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"` } // PushSubscription is one browser/device Web Push endpoint a user granted. diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go new file mode 100644 index 0000000..fd60a4b --- /dev/null +++ b/internal/repository/comment_repo.go @@ -0,0 +1,351 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/prabhupadasays/spsays-bff/internal/model" + "go.uber.org/zap" +) + +var ( + // ErrCommentNotFound — no comment with that id (or not in the expected + // scope, e.g. pinning a reply). + ErrCommentNotFound = errors.New("comment not found") + // ErrDuplicateComment — the same user re-posted identical text in the same + // scope (caught by the dedup unique index). + ErrDuplicateComment = errors.New("duplicate comment") +) + +// CommentRepo handles post comments, replies, comment-likes, and pinning. +// Comment likes are far lower-volume than post likes, so likes_count is kept +// in sync transactionally rather than batched through Redis. +type CommentRepo struct { + pg *Postgres + logger *zap.Logger +} + +func NewCommentRepo(pg *Postgres, logger *zap.Logger) *CommentRepo { + return &CommentRepo{pg: pg, logger: logger} +} + +// nullUUIDArg returns nil for an empty id so a `$n::uuid` parameter binds to +// SQL NULL (anonymous viewer) instead of failing uuid parsing on "". +func nullUUIDArg(s string) *string { + if s == "" { + return nil + } + return &s +} + +// commentSelect is the shared column list for reading a comment with its +// author, the reply-to handle, the viewer's like state, and the reply count. +// `vl` is a viewer-scoped LEFT JOIN on comment_likes; `ru` the reply-to user. +const commentSelect = ` + c.id, c.post_id, c.user_id, c.parent_id, c.body, c.likes_count, + c.pinned_at, c.created_at, c.updated_at, + 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)` + +// The viewer id always rides in parameter slot $2 across the read queries +// below, so the shared viewer-scoped like join can hardcode it. +const commentFrom = ` + FROM post_comments c + JOIN users u ON u.id = c.user_id + LEFT JOIN users ru ON ru.id = c.reply_to_user_id + LEFT JOIN comment_likes vl ON vl.comment_id = c.id AND vl.user_id = $2::uuid` + +func scanComment(row pgx.Row, c *model.Comment) error { + return row.Scan(&c.ID, &c.PostID, &c.UserID, &c.ParentID, &c.Body, &c.LikesCount, + &c.PinnedAt, &c.CreatedAt, &c.UpdatedAt, + &c.AuthorName, &c.AuthorHandle, &c.AuthorAvatarURL, + &c.ReplyToHandle, + &c.ViewerHasLiked, &c.ReplyCount) +} + +func scanComments(rows pgx.Rows) ([]model.Comment, error) { + defer rows.Close() + out := []model.Comment{} + for rows.Next() { + var c model.Comment + if err := scanComment(rows, &c); err != nil { + return nil, fmt.Errorf("scan comment: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +// PostAuthor returns the id of the user who owns the post, or ErrPostNotFound. +func (r *CommentRepo) PostAuthor(ctx context.Context, postID string) (string, error) { + var uid string + err := r.pg.Pool.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1`, postID).Scan(&uid) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrPostNotFound + } + if err != nil { + return "", fmt.Errorf("post author: %w", err) + } + return uid, nil +} + +// Create inserts a comment. parentID is the thread root (nil for a top-level +// comment); replyToUserID is who a reply addresses (nil for top-level). +// Returns ErrDuplicateComment when the same user re-posts identical text in +// the same scope. The returned Comment carries author fields. +func (r *CommentRepo) Create(ctx context.Context, postID, userID string, parentID, replyToUserID *string, body string) (*model.Comment, error) { + var id string + err := r.pg.Pool.QueryRow(ctx, ` + INSERT INTO post_comments (post_id, user_id, parent_id, reply_to_user_id, body) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (post_id, user_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(body)) + DO NOTHING + RETURNING id`, + postID, userID, parentID, replyToUserID, body, + ).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDuplicateComment + } + if err != nil { + return nil, fmt.Errorf("insert comment: %w", err) + } + // The author hasn't liked their own brand-new comment, so viewer state is + // resolved against userID (yields false) — fine for the create response. + return r.Get(ctx, id, userID) +} + +// 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` + c := &model.Comment{} + err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrCommentNotFound + } + if err != nil { + return nil, fmt.Errorf("get comment: %w", err) + } + return c, nil +} + +// ListTopLevel returns a page of a post's top-level comments — pinned first, +// then newest — plus the total top-level count. +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`, + postID).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count comments: %w", err) + } + if total == 0 { + return []model.Comment{}, 0, nil + } + q := `SELECT ` + commentSelect + commentFrom + ` + WHERE c.post_id = $1 AND c.parent_id 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) + if err != nil { + return nil, 0, fmt.Errorf("list comments: %w", err) + } + out, err := scanComments(rows) + return out, total, err +} + +// ListReplies returns a page of a thread's replies (oldest first) + the total. +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 { + 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 + ORDER BY c.created_at ASC + LIMIT $3 OFFSET $4` + rows, err := r.pg.Pool.Query(ctx, q, parentID, nullUUIDArg(viewerID), limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("list replies: %w", err) + } + out, err := scanComments(rows) + return out, total, err +} + +// HydratePinned attaches each post's pinned comment (if any) to +// Post.PinnedComment in a single query for the page. +func (r *CommentRepo) HydratePinned(ctx context.Context, viewerID string, posts []*model.Post) error { + ids := make([]string, 0, len(posts)) + for _, p := range posts { + if p != nil { + ids = append(ids, p.ID) + } + } + if len(ids) == 0 { + return nil + } + q := `SELECT ` + commentSelect + commentFrom + ` + WHERE c.post_id = ANY($1) AND c.pinned_at IS NOT NULL` + rows, err := r.pg.Pool.Query(ctx, q, ids, nullUUIDArg(viewerID)) + if err != nil { + return fmt.Errorf("hydrate pinned: %w", err) + } + list, err := scanComments(rows) + if err != nil { + return err + } + byPost := make(map[string]*model.Comment, len(list)) + for i := range list { + byPost[list[i].PostID] = &list[i] + } + for _, p := range posts { + if p == nil { + continue + } + if c, ok := byPost[p.ID]; ok { + p.PinnedComment = c + } + } + return nil +} + +// Like / Unlike toggle the viewer's like on a comment and return the new +// likes_count. Idempotent; the membership change and counter update happen in +// one transaction so concurrent likes stay consistent. +func (r *CommentRepo) Like(ctx context.Context, commentID, userID string) (int, error) { + return r.setLike(ctx, commentID, userID, true) +} + +func (r *CommentRepo) Unlike(ctx context.Context, commentID, userID string) (int, error) { + return r.setLike(ctx, commentID, userID, false) +} + +func (r *CommentRepo) setLike(ctx context.Context, commentID, userID string, like bool) (int, error) { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin like txn: %w", err) + } + defer tx.Rollback(ctx) + + // Lock the comment row up front: this validates existence (clean 404 + // instead of a leaked FK violation) and serializes concurrent likes so + // the counter stays exact. + var count int + if err := tx.QueryRow(ctx, + `SELECT likes_count FROM post_comments WHERE id = $1 FOR UPDATE`, commentID).Scan(&count); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrCommentNotFound + } + return 0, fmt.Errorf("read comment likes_count: %w", err) + } + + if like { + tag, err := tx.Exec(ctx, + `INSERT INTO comment_likes (comment_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + commentID, userID) + if err != nil { + return 0, fmt.Errorf("insert comment like: %w", err) + } + if tag.RowsAffected() > 0 { + count++ + if _, err := tx.Exec(ctx, + `UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil { + return 0, fmt.Errorf("bump comment likes: %w", err) + } + } + } else { + tag, err := tx.Exec(ctx, + `DELETE FROM comment_likes WHERE comment_id = $1 AND user_id = $2`, commentID, userID) + if err != nil { + return 0, fmt.Errorf("delete comment like: %w", err) + } + if tag.RowsAffected() > 0 { + if count > 0 { + count-- + } + if _, err := tx.Exec(ctx, + `UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil { + return 0, fmt.Errorf("drop comment likes: %w", err) + } + } + } + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit like txn: %w", err) + } + return count, nil +} + +// Pin makes commentID the post's single pinned comment (top-level only), +// unpinning any previously pinned comment in the same transaction. Returns +// ErrCommentNotFound when the comment isn't a top-level comment of postID. +func (r *CommentRepo) Pin(ctx context.Context, postID, commentID string) error { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin pin txn: %w", err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, + `UPDATE post_comments SET pinned_at = NULL WHERE post_id = $1 AND pinned_at IS NOT NULL`, + postID); err != nil { + return fmt.Errorf("unpin existing: %w", err) + } + tag, err := tx.Exec(ctx, + `UPDATE post_comments SET pinned_at = now(), updated_at = now() + WHERE id = $1 AND post_id = $2 AND parent_id IS NULL`, + commentID, postID) + if err != nil { + return fmt.Errorf("pin comment: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrCommentNotFound + } + return tx.Commit(ctx) +} + +// Unpin clears the pin on commentID for postID. +func (r *CommentRepo) Unpin(ctx context.Context, postID, commentID string) error { + tag, err := r.pg.Pool.Exec(ctx, + `UPDATE post_comments SET pinned_at = NULL WHERE id = $1 AND post_id = $2`, + commentID, postID) + if err != nil { + return fmt.Errorf("unpin comment: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrCommentNotFound + } + return nil +} + +// Delete removes the comment. For a thread root this cascades to its replies +// (and all comment_likes) via ON DELETE CASCADE. Authorization is the caller's +// responsibility. +func (r *CommentRepo) Delete(ctx context.Context, commentID string) error { + tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM post_comments WHERE id = $1`, commentID) + if err != nil { + return fmt.Errorf("delete comment: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrCommentNotFound + } + return nil +} + +// CreateReport files a report against a comment. +func (r *CommentRepo) CreateReport(ctx context.Context, reporterID, commentID, category, reason string) error { + if _, err := r.pg.Pool.Exec(ctx, + `INSERT INTO comment_reports (reporter_id, comment_id, category, reason) + VALUES ($1, $2, $3, $4)`, + reporterID, commentID, category, reason, + ); err != nil { + return fmt.Errorf("create comment report: %w", err) + } + return nil +} diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index cdf1120..bf84bae 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -324,9 +324,12 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, rows, err := r.pg.Pool.Query(ctx, ` 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 + u.name, u.handle, u.avatar_url, + pu.handle FROM notifications n LEFT JOIN users u ON u.id = n.actor_id + LEFT JOIN posts p ON p.id = n.post_id + LEFT JOIN users pu ON pu.id = p.user_id WHERE n.user_id = $1 AND NOT EXISTS ( SELECT 1 FROM notification_prefs np @@ -344,7 +347,8 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, var n model.Notification 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); err != nil { + &n.ActorName, &n.ActorHandle, &n.ActorAvatar, + &n.PostAuthorHandle); err != nil { return nil, fmt.Errorf("scan notification: %w", err) } out = append(out, n) @@ -400,6 +404,36 @@ func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, p return nil } +// CreateComment persists a comment/reply notification linked to the post. +// kind is 'post_comment' (to the post author) or 'comment_reply' (to the +// replied-to comment's author). Like mentions, no aggregation. +func (r *NotificationRepo) CreateComment(ctx context.Context, userID, actorID, postID, kind string) error { + if _, err := r.pg.Pool.Exec(ctx, + `INSERT INTO notifications (user_id, actor_id, kind, post_id) + VALUES ($1, $2, $3, $4)`, + userID, actorID, kind, postID, + ); err != nil { + return fmt.Errorf("create comment notification: %w", err) + } + return nil +} + +// PostAuthorHandle returns the handle of the user who owns the post, for +// building a deep-link. Empty string (no error) when the post is gone. +func (r *NotificationRepo) PostAuthorHandle(ctx context.Context, postID string) (string, error) { + var h string + err := r.pg.Pool.QueryRow(ctx, + `SELECT u.handle FROM posts p JOIN users u ON u.id = p.user_id WHERE p.id = $1`, + postID).Scan(&h) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("post author handle: %w", err) + } + return h, nil +} + // CountUnread returns how many of a recipient's notifications are unread, // excluding kinds the user has turned off for the in-app channel (matching // ListByUser, so the badge never counts rows the feed won't show). diff --git a/internal/server/server.go b/internal/server/server.go index 20e7f4e..f41bc07 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -51,6 +51,7 @@ type Server struct { oauthH *handler.OAuthHandler userH *handler.UserHandler postH *handler.PostHandler + commentH *handler.CommentHandler bookmarkH *handler.BookmarkHandler collectionH *handler.CollectionHandler profileH *handler.ProfileHandler @@ -166,7 +167,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { // (s.redis may be nil — LikeRepo then applies counts directly). likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger) mentionRepo := repository.NewMentionRepo(s.pg) - postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo) + commentRepo := repository.NewCommentRepo(s.pg, logger) + postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo) bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo) collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2) @@ -219,6 +221,13 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { // the author (push + realtime; not the liker themselves). postSvc.SetNotifier(notifSvc) + // Comments: comment / reply / like / pin / report over the same Postgres + // pool, with the notification seam so new comments notify the post author + // and replies notify the comment they answer. + commentSvc := service.NewCommentService(commentRepo) + commentSvc.SetNotifier(notifSvc) + s.commentH = handler.NewCommentHandler(commentSvc) + // Sādhana reminders: a background worker nudges users (email + push + // websocket) to chant / post a quote at their local time. Shares the // notification seam above so all three channels fire. @@ -239,7 +248,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { s.relationH = handler.NewRelationshipHandler(graphSvc) s.notificationH = handler.NewNotificationHandler(notifSvc) s.postH = handler.NewPostHandler(postSvc, graphSvc) - s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo, mentionRepo) + s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo, mentionRepo, commentRepo) // Sādhana streak: daily chanting log + the growing-plant state. Reads // the chanting half from sadhna_logs and the posting half from the @@ -647,6 +656,10 @@ func (s *Server) setupRouter() { r.Get("/{id}/likes", s.postH.ListLikes) // Tagged users on a post — public (read-only for non-authors). r.Get("/{id}/mentions", s.postH.ListMentions) + // Comments — public read (viewer_has_liked populated when signed in). + if s.commentH != nil { + r.Get("/{id}/comments", s.commentH.List) + } }) // Protected — create / delete / image presign / "my posts" / // reshare actions. @@ -672,6 +685,37 @@ func (s *Server) setupRouter() { r.Delete("/{id}/mentions/{userID}", s.postH.RemoveMention) // Report a post (category + optional reason) → admin email. r.Post("/{id}/report", s.postH.Report) + // Add a comment / reply (anyone signed in). + if s.commentH != nil { + r.Post("/{id}/comments", s.commentH.Create) + } + }) + }) + + // --- Social: comments (replies, likes, pin, delete, report) --- + // Keyed by comment id; the post-scoped list/create live under /posts. + r.Route("/comments", func(r chi.Router) { + if s.commentH == nil { + r.HandleFunc("/*", s.authUnavailable) + return + } + // Public read — a thread's replies (viewer_has_liked when signed in). + r.Group(func(r chi.Router) { + r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) + r.Get("/{id}/replies", s.commentH.Replies) + }) + // Protected — like / pin / delete / report. + r.Group(func(r chi.Router) { + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Post("/{id}/like", s.commentH.Like) + r.Delete("/{id}/like", s.commentH.Unlike) + // Pin / unpin — post author only (enforced in the service). + r.Post("/{id}/pin", s.commentH.Pin) + r.Delete("/{id}/pin", s.commentH.Unpin) + // Delete — comment author or post author; removes the thread. + r.Delete("/{id}", s.commentH.Delete) + // Report a comment (category + optional reason). + r.Post("/{id}/report", s.commentH.Report) }) }) diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go new file mode 100644 index 0000000..e961f13 --- /dev/null +++ b/internal/service/comment_service.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" +) + +// maxCommentLen caps a comment/reply body. +const maxCommentLen = 2000 + +// CommentService is the comment / reply / like / pin / report brain. It sits +// over CommentRepo, enforces the rules (non-empty body, author-only pin, +// author-or-post-owner delete, one-level threading), and routes new +// comments/replies through the NotificationService. +type CommentService struct { + comments *repository.CommentRepo + notif *NotificationService // optional; set via SetNotifier +} + +func NewCommentService(comments *repository.CommentRepo) *CommentService { + return &CommentService{comments: comments} +} + +// SetNotifier wires the notification seam after construction. +func (s *CommentService) SetNotifier(n *NotificationService) { s.notif = n } + +// Add posts a comment or reply. parentID nil → a top-level comment (the post +// author is notified). Non-nil → a reply: the new comment is flattened onto +// the thread root and addressed to the target comment's author, who is the one +// notified. Anyone signed in can comment. Returns ErrDuplicateComment when the +// same user re-posts identical text in the same scope. +func (s *CommentService) Add(ctx context.Context, viewerID, postID string, parentID *string, body string) (*model.Comment, error) { + body = strings.TrimSpace(body) + if body == "" { + return nil, errors.New("comment can't be empty") + } + if len(body) > maxCommentLen { + return nil, fmt.Errorf("comment too long (max %d)", maxCommentLen) + } + // Validate the post exists + grab its author for the comment notification. + postAuthor, err := s.comments.PostAuthor(ctx, postID) + if err != nil { + return nil, err // ErrPostNotFound → 404 + } + + var rootID *string // thread root the new comment hangs under + var replyToUserID *string // who a reply addresses (for the @handle prefix) + notifyUserID := postAuthor + isReply := parentID != nil + + if isReply { + target, err := s.comments.Get(ctx, *parentID, viewerID) + if err != nil { + return nil, err // ErrCommentNotFound → 404 + } + if target.PostID != postID { + return nil, ErrNotFound // parent belongs to a different post + } + // Flatten: replies always hang under the thread ROOT, never under + // another reply — so every reply lives in one thread. The reply is + // addressed to whoever we replied to, and they're the one notified. + root := target.ID + if target.ParentID != nil { + root = *target.ParentID + } + rootID = &root + replyToUserID = &target.UserID + notifyUserID = target.UserID + } + + c, err := s.comments.Create(ctx, postID, viewerID, rootID, replyToUserID, body) + if err != nil { + return nil, err // ErrDuplicateComment → 409 via mapSocialErr + } + + // Fire-and-forget on a detached context so it isn't cancelled when the + // request returns. Never notify yourself. + if s.notif != nil && notifyUserID != "" && notifyUserID != viewerID { + if isReply { + go s.notif.NotifyCommentReply(context.Background(), postID, notifyUserID, viewerID) + } else { + go s.notif.NotifyPostComment(context.Background(), postID, notifyUserID, viewerID) + } + } + return c, nil +} + +// List returns a page of a post's top-level comments (pinned first) + total. +func (s *CommentService) List(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) { + return s.comments.ListTopLevel(ctx, postID, viewerID, limit, offset) +} + +// Replies returns a page of a thread's replies (oldest first) + total. +func (s *CommentService) Replies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) { + return s.comments.ListReplies(ctx, parentID, viewerID, limit, offset) +} + +// Like / Unlike toggle the caller's like on a comment; return the new count. +func (s *CommentService) Like(ctx context.Context, viewerID, commentID string) (int, error) { + return s.comments.Like(ctx, commentID, viewerID) +} + +func (s *CommentService) Unlike(ctx context.Context, viewerID, commentID string) (int, error) { + return s.comments.Unlike(ctx, commentID, viewerID) +} + +// Pin / Unpin — post author only, and only top-level comments are pinnable. +func (s *CommentService) Pin(ctx context.Context, viewerID, commentID string) error { + c, err := s.requirePostOwner(ctx, viewerID, commentID) + if err != nil { + return err + } + if c.ParentID != nil { + return errors.New("only top-level comments can be pinned") + } + return s.comments.Pin(ctx, c.PostID, commentID) +} + +func (s *CommentService) Unpin(ctx context.Context, viewerID, commentID string) error { + c, err := s.requirePostOwner(ctx, viewerID, commentID) + if err != nil { + return err + } + return s.comments.Unpin(ctx, c.PostID, commentID) +} + +// Delete removes a comment (and, for a thread root, its whole thread). Allowed +// for the comment's author or the post's author. +func (s *CommentService) Delete(ctx context.Context, viewerID, commentID string) error { + c, err := s.comments.Get(ctx, commentID, viewerID) + if err != nil { + return err + } + if c.UserID != viewerID { + author, err := s.comments.PostAuthor(ctx, c.PostID) + if err != nil { + return err + } + if author != viewerID { + return ErrForbidden + } + } + return s.comments.Delete(ctx, commentID) +} + +// Report files a report against a comment. Anyone signed in can report; the +// category must be in the (post) report allow-list and the reason is optional. +func (s *CommentService) Report(ctx context.Context, viewerID, commentID, category, reason string) error { + category = strings.ToLower(strings.TrimSpace(category)) + if !postReportCategories[category] { + return errors.New("invalid report category") + } + reason = strings.TrimSpace(reason) + if len(reason) > 2000 { + return errors.New("reason too long (max 2000)") + } + if _, err := s.comments.Get(ctx, commentID, viewerID); err != nil { + return err // ErrCommentNotFound → 404 + } + return s.comments.CreateReport(ctx, viewerID, commentID, category, reason) +} + +// requirePostOwner loads the comment and verifies the viewer owns the post it +// belongs to (used to gate pin/unpin). Returns the comment on success. +func (s *CommentService) requirePostOwner(ctx context.Context, viewerID, commentID string) (*model.Comment, error) { + c, err := s.comments.Get(ctx, commentID, viewerID) + if err != nil { + return nil, err + } + author, err := s.comments.PostAuthor(ctx, c.PostID) + if err != nil { + return nil, err + } + if author != viewerID { + return nil, ErrForbidden + } + return c, nil +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index dd53d45..887ba65 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -35,6 +35,8 @@ const ( NotifFollowAccepted = "follow_accepted" NotifPostLike = "post_like" NotifPostMention = "post_mention" + NotifPostComment = "post_comment" + NotifCommentReply = "comment_reply" // Reminder kinds are delivered live (email + push + a websocket toast) // but NOT persisted — they're ephemeral nudges, not history. @@ -48,7 +50,7 @@ const ( // opt-outs map exactly), but the UI reads/writes whole categories. var PrefCategories = map[string][]string{ "follows": {NotifFollowRequest, NotifFollowAccepted}, - "post_interactions": {NotifPostLike, NotifPostMention}, + "post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply}, "sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete}, } @@ -351,6 +353,59 @@ func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID }) } +// NotifyPostComment tells the post author that `commenterID` commented on +// their post. NotifyCommentReply tells `recipientID` (the author of the +// comment being replied to) that `replierID` replied. Both: persisted row + +// realtime + Web Push, no email (comments are interactive, not digest-worthy). +// Self-actions are skipped. +func (s *NotificationService) NotifyPostComment(ctx context.Context, postID, authorID, commenterID string) { + s.notifyComment(ctx, NotifPostComment, postID, authorID, commenterID) +} + +func (s *NotificationService) NotifyCommentReply(ctx context.Context, postID, recipientID, replierID string) { + s.notifyComment(ctx, NotifCommentReply, postID, recipientID, replierID) +} + +func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, recipientID, actorID string) { + if recipientID == "" || actorID == "" || recipientID == actorID { + return + } + off := s.disabledPrefs(ctx, recipientID) + if err := s.notifs.CreateComment(ctx, recipientID, actorID, postID, kind); err != nil { + s.logger.Warn("persist comment notification failed", + zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err)) + } + actorUser, _ := s.users.FindByID(ctx, actorID) + if !prefOff(off, kind, repository.ChannelInApp) { + s.publishRealtime(ctx, recipientID, kind, actorUser) + } + + if s.push == nil || prefOff(off, kind, repository.ChannelPush) { + return + } + who := "Someone" + if actorUser != nil { + who = actorDisplay(actorUser) + } + // Tapping it opens the post (via its author's handle — the actor isn't the + // post owner on a reply). Falls back to the notification center. + url := s.frontendURL + "/notifications" + if h, err := s.notifs.PostAuthorHandle(ctx, postID); err == nil && h != "" { + url = s.frontendURL + "/u/" + h + "/posts#post-" + postID + } + title, body := "New comment", who+" commented on your post" + if kind == NotifCommentReply { + title, body = "New reply", who+" replied to your comment" + } + s.push.SendToUser(ctx, recipientID, PushPayload{ + Title: title, + Body: body, + URL: url, + Icon: s.iconURL(), + Tag: kind + ":" + postID, + }) +} + // likeMessage renders the aggregated like text: "X liked your post" or // "X and N others liked your post". func likeMessage(who string, count int) string { diff --git a/internal/service/social_service.go b/internal/service/social_service.go index fdf4327..1485654 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -35,11 +35,12 @@ type PostService struct { r2 *repository.R2 likes *repository.LikeRepo mentions *repository.MentionRepo + comments *repository.CommentRepo notif *NotificationService // optional; set via SetNotifier } -func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo) *PostService { - return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions} +func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo) *PostService { + return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments} } // SetNotifier wires the notification seam after construction (the @@ -97,6 +98,9 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil { return nil, err } + if err := s.comments.HydratePinned(ctx, viewerID, []*model.Post{p}); err != nil { + return nil, err + } return p, nil } @@ -202,9 +206,24 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, if err := s.hydrateMentionsSlice(ctx, posts); err != nil { return nil, 0, err } + if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil { + return nil, 0, err + } return posts, total, nil } +// hydratePinnedSlice fills PinnedComment on a value slice. +func (s *PostService) hydratePinnedSlice(ctx context.Context, viewerID string, posts []model.Post) error { + if len(posts) == 0 { + return nil + } + ptrs := make([]*model.Post, len(posts)) + for i := range posts { + ptrs[i] = &posts[i] + } + return s.comments.HydratePinned(ctx, viewerID, ptrs) +} + // hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice. func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error { if len(posts) == 0 { @@ -347,6 +366,9 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset if err := s.hydrateMentionsSlice(ctx, posts); err != nil { return nil, 0, err } + if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil { + return nil, 0, err + } return posts, total, nil } diff --git a/migrations/0036_create_post_comments.down.sql b/migrations/0036_create_post_comments.down.sql new file mode 100644 index 0000000..ac979c6 --- /dev/null +++ b/migrations/0036_create_post_comments.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS comment_reports; +DROP TABLE IF EXISTS comment_likes; +DROP TABLE IF EXISTS post_comments; diff --git a/migrations/0036_create_post_comments.up.sql b/migrations/0036_create_post_comments.up.sql new file mode 100644 index 0000000..9d6aa3a --- /dev/null +++ b/migrations/0036_create_post_comments.up.sql @@ -0,0 +1,62 @@ +-- Post comments — two-level threads, likes, pinning, reports. +-- +-- Threading is deliberately ONE level deep: `parent_id` is NULL for a +-- top-level comment and otherwise points at a TOP-LEVEL comment (the thread +-- root). When a user replies to a reply, the service flattens the new comment +-- onto the same root and records `reply_to_user_id` so the UI can render the +-- "@handle" it was addressed to — so every reply lives under one thread. +-- ON DELETE CASCADE on parent_id means deleting a root removes its whole thread. +CREATE TABLE IF NOT EXISTS post_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + parent_id UUID REFERENCES post_comments(id) ON DELETE CASCADE, + -- The user a reply is addressed to (for the "@handle" prefix). NULL on + -- top-level comments. SET NULL if that user is later deleted. + reply_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + body TEXT NOT NULL, + -- Denormalized like counter (the UI shows only the count; comment_likes + -- keeps the relations). Kept in sync on like/unlike in the same txn — comment + -- likes are far lower-volume than post likes, so no Redis batching here. + likes_count INTEGER NOT NULL DEFAULT 0, + pinned_at TIMESTAMPTZ, -- non-null = pinned (top-level only) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Dedup: a user can't post the identical text twice in the same scope (post + +-- thread). COALESCE folds top-level (NULL parent) onto a fixed sentinel so the +-- index covers it; md5(body) keeps the key small for long comments. +CREATE UNIQUE INDEX IF NOT EXISTS post_comments_dedup + ON post_comments (post_id, user_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(body)); + +-- Listing a post's top-level comments, and a thread's replies (oldest first). +CREATE INDEX IF NOT EXISTS post_comments_post_idx ON post_comments (post_id, created_at); +CREATE INDEX IF NOT EXISTS post_comments_parent_idx ON post_comments (parent_id, created_at); + +-- At most one pinned comment per post. +CREATE UNIQUE INDEX IF NOT EXISTS post_comments_one_pin + ON post_comments (post_id) WHERE pinned_at IS NOT NULL; + +-- Comment likes — membership rows backing the displayed count. Idempotent via PK. +CREATE TABLE IF NOT EXISTS comment_likes ( + comment_id UUID NOT NULL REFERENCES post_comments(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (comment_id, user_id) +); +-- "Which comments has this viewer liked?" — viewer-state hydration across a thread. +CREATE INDEX IF NOT EXISTS comment_likes_user_idx ON comment_likes (user_id); + +-- Comment reports — mirrors post_reports, keyed to a comment. +CREATE TABLE IF NOT EXISTS comment_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + comment_id UUID NOT NULL REFERENCES post_comments(id) ON DELETE CASCADE, + category VARCHAR(32) NOT NULL, + reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS comment_reports_unresolved_idx + ON comment_reports (created_at DESC) WHERE resolved_at IS NULL;