feat: add comments
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -545,10 +545,11 @@ type ProfileHandler struct {
|
|||||||
graph *service.SocialGraphService
|
graph *service.SocialGraphService
|
||||||
likes *repository.LikeRepo
|
likes *repository.LikeRepo
|
||||||
mentions *repository.MentionRepo
|
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 {
|
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}
|
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) {
|
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 {
|
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
|
||||||
return err
|
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:
|
// followListResp is the payload for the followers / following list endpoints:
|
||||||
@@ -755,10 +759,13 @@ func mapSocialErr(w http.ResponseWriter, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, repository.ErrUserNotFound),
|
case errors.Is(err, repository.ErrUserNotFound),
|
||||||
errors.Is(err, repository.ErrPostNotFound),
|
errors.Is(err, repository.ErrPostNotFound),
|
||||||
|
errors.Is(err, repository.ErrCommentNotFound),
|
||||||
errors.Is(err, repository.ErrBookmarkNotFound),
|
errors.Is(err, repository.ErrBookmarkNotFound),
|
||||||
errors.Is(err, repository.ErrCollectionNotFound),
|
errors.Is(err, repository.ErrCollectionNotFound),
|
||||||
errors.Is(err, service.ErrNotFound):
|
errors.Is(err, service.ErrNotFound):
|
||||||
response.Error(w, http.StatusNotFound, "not found")
|
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):
|
case errors.Is(err, service.ErrForbidden):
|
||||||
response.Error(w, http.StatusForbidden, "not allowed")
|
response.Error(w, http.StatusForbidden, "not allowed")
|
||||||
case errors.Is(err, service.ErrBlocked):
|
case errors.Is(err, service.ErrBlocked):
|
||||||
|
|||||||
@@ -50,6 +50,42 @@ type Post struct {
|
|||||||
// MentionsCount is how many users are tagged on this post (drives the
|
// MentionsCount is how many users are tagged on this post (drives the
|
||||||
// post-card mentions icon). Hydrated by the mention layer.
|
// post-card mentions icon). Hydrated by the mention layer.
|
||||||
MentionsCount int `json:"mentions_count"`
|
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.
|
// PostImage represents one attached image on a post.
|
||||||
@@ -188,6 +224,11 @@ type Notification struct {
|
|||||||
ActorName *string `json:"actor_name,omitempty"`
|
ActorName *string `json:"actor_name,omitempty"`
|
||||||
ActorHandle *string `json:"actor_handle,omitempty"`
|
ActorHandle *string `json:"actor_handle,omitempty"`
|
||||||
ActorAvatar *string `json:"actor_avatar_url,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.
|
// PushSubscription is one browser/device Web Push endpoint a user granted.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -324,9 +324,12 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
|||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
||||||
n.created_at, n.read_at,
|
n.created_at, n.read_at,
|
||||||
u.name, u.handle, u.avatar_url
|
u.name, u.handle, u.avatar_url,
|
||||||
|
pu.handle
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN users u ON u.id = n.actor_id
|
LEFT JOIN users u ON u.id = n.actor_id
|
||||||
|
LEFT JOIN posts p ON p.id = n.post_id
|
||||||
|
LEFT JOIN users pu ON pu.id = p.user_id
|
||||||
WHERE n.user_id = $1
|
WHERE n.user_id = $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM notification_prefs np
|
SELECT 1 FROM notification_prefs np
|
||||||
@@ -344,7 +347,8 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
|||||||
var n model.Notification
|
var n model.Notification
|
||||||
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
||||||
&n.CreatedAt, &n.ReadAt,
|
&n.CreatedAt, &n.ReadAt,
|
||||||
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
|
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
|
||||||
|
&n.PostAuthorHandle); err != nil {
|
||||||
return nil, fmt.Errorf("scan notification: %w", err)
|
return nil, fmt.Errorf("scan notification: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, n)
|
out = append(out, n)
|
||||||
@@ -400,6 +404,36 @@ func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, p
|
|||||||
return nil
|
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,
|
// CountUnread returns how many of a recipient's notifications are unread,
|
||||||
// excluding kinds the user has turned off for the in-app channel (matching
|
// 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).
|
// ListByUser, so the badge never counts rows the feed won't show).
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type Server struct {
|
|||||||
oauthH *handler.OAuthHandler
|
oauthH *handler.OAuthHandler
|
||||||
userH *handler.UserHandler
|
userH *handler.UserHandler
|
||||||
postH *handler.PostHandler
|
postH *handler.PostHandler
|
||||||
|
commentH *handler.CommentHandler
|
||||||
bookmarkH *handler.BookmarkHandler
|
bookmarkH *handler.BookmarkHandler
|
||||||
collectionH *handler.CollectionHandler
|
collectionH *handler.CollectionHandler
|
||||||
profileH *handler.ProfileHandler
|
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).
|
// (s.redis may be nil — LikeRepo then applies counts directly).
|
||||||
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
|
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
|
||||||
mentionRepo := repository.NewMentionRepo(s.pg)
|
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)
|
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
|
||||||
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
|
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).
|
// the author (push + realtime; not the liker themselves).
|
||||||
postSvc.SetNotifier(notifSvc)
|
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 +
|
// Sādhana reminders: a background worker nudges users (email + push +
|
||||||
// websocket) to chant / post a quote at their local time. Shares the
|
// websocket) to chant / post a quote at their local time. Shares the
|
||||||
// notification seam above so all three channels fire.
|
// 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.relationH = handler.NewRelationshipHandler(graphSvc)
|
||||||
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
||||||
s.postH = handler.NewPostHandler(postSvc, graphSvc)
|
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
|
// Sādhana streak: daily chanting log + the growing-plant state. Reads
|
||||||
// the chanting half from sadhna_logs and the posting half from the
|
// 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)
|
r.Get("/{id}/likes", s.postH.ListLikes)
|
||||||
// Tagged users on a post — public (read-only for non-authors).
|
// Tagged users on a post — public (read-only for non-authors).
|
||||||
r.Get("/{id}/mentions", s.postH.ListMentions)
|
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" /
|
// Protected — create / delete / image presign / "my posts" /
|
||||||
// reshare actions.
|
// reshare actions.
|
||||||
@@ -672,6 +685,37 @@ func (s *Server) setupRouter() {
|
|||||||
r.Delete("/{id}/mentions/{userID}", s.postH.RemoveMention)
|
r.Delete("/{id}/mentions/{userID}", s.postH.RemoveMention)
|
||||||
// Report a post (category + optional reason) → admin email.
|
// Report a post (category + optional reason) → admin email.
|
||||||
r.Post("/{id}/report", s.postH.Report)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ const (
|
|||||||
NotifFollowAccepted = "follow_accepted"
|
NotifFollowAccepted = "follow_accepted"
|
||||||
NotifPostLike = "post_like"
|
NotifPostLike = "post_like"
|
||||||
NotifPostMention = "post_mention"
|
NotifPostMention = "post_mention"
|
||||||
|
NotifPostComment = "post_comment"
|
||||||
|
NotifCommentReply = "comment_reply"
|
||||||
|
|
||||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||||
// but NOT persisted — they're ephemeral nudges, not history.
|
// 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.
|
// opt-outs map exactly), but the UI reads/writes whole categories.
|
||||||
var PrefCategories = map[string][]string{
|
var PrefCategories = map[string][]string{
|
||||||
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
||||||
"post_interactions": {NotifPostLike, NotifPostMention},
|
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
"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
|
// likeMessage renders the aggregated like text: "X liked your post" or
|
||||||
// "X and N others liked your post".
|
// "X and N others liked your post".
|
||||||
func likeMessage(who string, count int) string {
|
func likeMessage(who string, count int) string {
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ type PostService struct {
|
|||||||
r2 *repository.R2
|
r2 *repository.R2
|
||||||
likes *repository.LikeRepo
|
likes *repository.LikeRepo
|
||||||
mentions *repository.MentionRepo
|
mentions *repository.MentionRepo
|
||||||
|
comments *repository.CommentRepo
|
||||||
notif *NotificationService // optional; set via SetNotifier
|
notif *NotificationService // optional; set via SetNotifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo) *PostService {
|
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}
|
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNotifier wires the notification seam after construction (the
|
// 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 {
|
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.comments.HydratePinned(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return p, nil
|
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 {
|
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
return posts, total, nil
|
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.
|
// hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice.
|
||||||
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||||
if len(posts) == 0 {
|
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 {
|
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
return posts, total, nil
|
return posts, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS comment_reports;
|
||||||
|
DROP TABLE IF EXISTS comment_likes;
|
||||||
|
DROP TABLE IF EXISTS post_comments;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user