feat: add post mention

This commit is contained in:
Krispire Admin
2026-06-10 16:28:57 +05:30
parent d86d876ac1
commit cb62adf2de
11 changed files with 472 additions and 34 deletions
+103 -24
View File
@@ -59,10 +59,11 @@ func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
} }
type createPostReq struct { type createPostReq struct {
Caption string `json:"caption"` Caption string `json:"caption"`
SourceURL string `json:"source_url"` SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"` SourceTitle string `json:"source_title"`
ImageURLs []string `json:"image_urls"` ImageURLs []string `json:"image_urls"`
MentionUserIDs []string `json:"mention_user_ids"`
} }
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
@@ -79,7 +80,7 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusBadRequest, v.Error()) response.Error(w, http.StatusBadRequest, v.Error())
return return
} }
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs) p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs, req.MentionUserIDs)
if err != nil { if err != nil {
mapSocialErr(w, err) mapSocialErr(w, err)
return return
@@ -87,6 +88,60 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusCreated, p) response.JSON(w, http.StatusCreated, p)
} }
// MentionSuggest — GET /posts/mention-suggest?q=&limit=&offset=. Prioritised
// user suggestions for the composer (following → followers → others).
func (h *PostHandler) MentionSuggest(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, err := h.svc.SuggestMentions(r.Context(), uid, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if users == nil {
users = []model.UserSummary{}
}
response.JSON(w, http.StatusOK, users)
}
// ListMentions — GET /posts/{id}/mentions. The users tagged on a post. Public.
func (h *PostHandler) ListMentions(w http.ResponseWriter, r *http.Request) {
users, err := h.svc.Mentions(r.Context(), chi.URLParam(r, "id"))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, users)
}
// AddMention — POST /posts/{id}/mentions { user_id }. Author only.
func (h *PostHandler) AddMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.UserID) == "" {
response.Error(w, http.StatusBadRequest, "missing user_id")
return
}
if err := h.svc.AddMention(r.Context(), uid, chi.URLParam(r, "id"), req.UserID); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// RemoveMention — DELETE /posts/{id}/mentions/{userID}. Author only.
func (h *PostHandler) RemoveMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.RemoveMention(r.Context(), uid, chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Reshare materialises a reshare of the post identified by {id} as a // Reshare materialises a reshare of the post identified by {id} as a
// new posts row owned by the caller. Idempotent — second call returns // new posts row owned by the caller. Idempotent — second call returns
// the same row (still 201). The body is the new (or existing) post so // the same row (still 201). The body is the new (or existing) post so
@@ -489,10 +544,11 @@ type ProfileHandler struct {
collections *repository.CollectionRepo collections *repository.CollectionRepo
graph *service.SocialGraphService graph *service.SocialGraphService
likes *repository.LikeRepo likes *repository.LikeRepo
mentions *repository.MentionRepo
} }
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo) *ProfileHandler { 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} return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes, mentions: mentions}
} }
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
@@ -550,21 +606,48 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
mapSocialErr(w, err) mapSocialErr(w, err)
return return
} }
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// MentionsFeed — GET /u/{handle}/mentions?limit=&offset=. Posts that tag this
// user, newest first. Public (OptionalAuth) with viewer-state hydration.
func (h *ProfileHandler) MentionsFeed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
limit, offset := parseLimitOffset(r)
posts, total, err := h.posts.ListMentioningByHandle(r.Context(), handle, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// hydrateFeed fills viewer reshare state + like counts/state + mention counts
// across a page of posts. Shared by the profile feed and the mentions feed.
func (h *ProfileHandler) hydrateFeed(r *http.Request, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
viewerID := middleware.UserID(r.Context()) viewerID := middleware.UserID(r.Context())
if viewerID != "" && len(posts) > 0 { if viewerID != "" {
ids := make([]string, 0, len(posts)) ids := make([]string, 0, len(posts))
for i := range posts { for i := range posts {
// Skip own posts — can't reshare yourself, and the swap if posts[i].UserID != viewerID { // can't reshare your own post
// already triggers on isOwner.
if posts[i].UserID != viewerID {
ids = append(ids, posts[i].ID) ids = append(ids, posts[i].ID)
} }
} }
if len(ids) > 0 { if len(ids) > 0 {
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids) states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
if err != nil { if err != nil {
mapSocialErr(w, err) return err
return
} }
for i := range posts { for i := range posts {
if states[posts[i].ID] { if states[posts[i].ID] {
@@ -573,18 +656,14 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
// Like counts (effective) + viewer-liked state for every post in the page. ptrs := make([]*model.Post, len(posts))
if len(posts) > 0 { for i := range posts {
ptrs := make([]*model.Post, len(posts)) ptrs[i] = &posts[i]
for i := range posts {
ptrs[i] = &posts[i]
}
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
mapSocialErr(w, err)
return
}
} }
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset)) if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
return err
}
return h.mentions.HydrateCounts(r.Context(), ptrs)
} }
// followListResp is the payload for the followers / following list endpoints: // followListResp is the payload for the followers / following list endpoints:
+4
View File
@@ -46,6 +46,10 @@ type Post struct {
// false for anonymous viewers. // false for anonymous viewers.
LikesCount int `json:"likes_count"` LikesCount int `json:"likes_count"`
ViewerHasLiked bool `json:"viewer_has_liked"` ViewerHasLiked bool `json:"viewer_has_liked"`
// 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"`
} }
// PostImage represents one attached image on a post. // PostImage represents one attached image on a post.
+3
View File
@@ -15,6 +15,9 @@ import (
// UserSummary is the lightweight projection used in follower / following // UserSummary is the lightweight projection used in follower / following
// lists — just enough to render a row and link to the profile. // lists — just enough to render a row and link to the profile.
type UserSummary struct { type UserSummary struct {
// ID is populated only where the caller needs it (e.g. mention add/remove);
// omitted from follower/like lists that link by handle.
ID string `json:"id,omitempty"`
Handle string `json:"handle"` Handle string `json:"handle"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL *string `json:"avatar_url"` AvatarURL *string `json:"avatar_url"`
+165
View File
@@ -0,0 +1,165 @@
package repository
import (
"context"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// maxMentionsPerPost caps how many users a post can tag.
const maxMentionsPerPost = 30
// MentionRepo handles post_mentions membership + mention suggestions.
type MentionRepo struct{ pg *Postgres }
func NewMentionRepo(pg *Postgres) *MentionRepo { return &MentionRepo{pg: pg} }
// Suggest returns up to `limit` users to mention, prioritised: people the
// viewer follows (rank 0), then people who follow the viewer (rank 1), then
// everyone else (rank 2). Excludes the viewer. Optional name/handle search.
// Offset paginates for the infinite-scroll popover.
func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
like := "%" + q + "%"
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url,
CASE
WHEN EXISTS (SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = u.id
AND f.status = 'accepted') THEN 0
WHEN EXISTS (SELECT 1 FROM follows f
WHERE f.follower_id = u.id AND f.following_id = $1
AND f.status = 'accepted') THEN 1
ELSE 2
END AS rank
FROM users u
WHERE u.id <> $1
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY rank, u.name, u.id
LIMIT $4 OFFSET $5`,
viewerID, q, like, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("suggest mentions: %w", err)
}
defer rows.Close()
out := make([]model.UserSummary, 0, limit)
for rows.Next() {
var s model.UserSummary
var rank int
if err := rows.Scan(&s.ID, &s.Handle, &s.Name, &s.AvatarURL, &rank); err != nil {
return nil, fmt.Errorf("scan suggestion: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// AddMany tags the given users on a post (idempotent, capped). Used on create.
func (r *MentionRepo) AddMany(ctx context.Context, postID string, userIDs []string) error {
if len(userIDs) > maxMentionsPerPost {
userIDs = userIDs[:maxMentionsPerPost]
}
for _, uid := range userIDs {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO post_mentions (post_id, user_id) VALUES ($1, $2)
ON CONFLICT (post_id, user_id) DO NOTHING`,
postID, uid,
); err != nil {
return fmt.Errorf("add mention: %w", err)
}
}
return nil
}
// Add tags a single user (idempotent), enforcing the per-post cap.
func (r *MentionRepo) Add(ctx context.Context, postID, userID string) error {
var n int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM post_mentions WHERE post_id = $1`, postID,
).Scan(&n); err != nil {
return fmt.Errorf("count mentions: %w", err)
}
if n >= maxMentionsPerPost {
return fmt.Errorf("a post can mention at most %d people", maxMentionsPerPost)
}
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO post_mentions (post_id, user_id) VALUES ($1, $2)
ON CONFLICT (post_id, user_id) DO NOTHING`,
postID, userID,
); err != nil {
return fmt.Errorf("add mention: %w", err)
}
return nil
}
// Remove untags a user from a post.
func (r *MentionRepo) Remove(ctx context.Context, postID, userID string) error {
if _, err := r.pg.Pool.Exec(ctx,
`DELETE FROM post_mentions WHERE post_id = $1 AND user_id = $2`,
postID, userID,
); err != nil {
return fmt.Errorf("remove mention: %w", err)
}
return nil
}
// ListForPost returns the users tagged on a post (insertion order).
func (r *MentionRepo) ListForPost(ctx context.Context, postID string) ([]model.UserSummary, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url
FROM post_mentions m JOIN users u ON u.id = m.user_id
WHERE m.post_id = $1
ORDER BY m.created_at ASC`, postID)
if err != nil {
return nil, fmt.Errorf("list mentions: %w", err)
}
defer rows.Close()
out := []model.UserSummary{}
for rows.Next() {
var s model.UserSummary
if err := rows.Scan(&s.ID, &s.Handle, &s.Name, &s.AvatarURL); err != nil {
return nil, fmt.Errorf("scan mention: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// HydrateCounts fills MentionsCount on the given posts (one grouped query).
func (r *MentionRepo) HydrateCounts(ctx context.Context, 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
}
rows, err := r.pg.Pool.Query(ctx,
`SELECT post_id, count(*) FROM post_mentions WHERE post_id = ANY($1) GROUP BY post_id`, ids)
if err != nil {
return fmt.Errorf("hydrate mention counts: %w", err)
}
counts := make(map[string]int, len(ids))
for rows.Next() {
var id string
var c int
if err := rows.Scan(&id, &c); err != nil {
rows.Close()
return fmt.Errorf("scan mention count: %w", err)
}
counts[id] = c
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, p := range posts {
if p != nil {
p.MentionsCount = counts[p.ID]
}
}
return nil
}
+26
View File
@@ -171,6 +171,32 @@ func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offse
return r.ListByUser(ctx, userID, limit, offset) return r.ListByUser(ctx, userID, limit, offset)
} }
// ListMentioningByHandle returns posts that tag the given user (handle),
// newest first — the "Mentions" profile tab. Same shape as the profile feed.
func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var userID string
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return []model.Post{}, 0, nil
}
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
}
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1`,
userID,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
JOIN post_mentions pm ON pm.post_id = p.id
WHERE pm.user_id = $1
ORDER BY p.created_at DESC, p.id
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// ListByUser returns the user's profile feed — every post they own, // ListByUser returns the user's profile feed — every post they own,
// authored or reshared. Reshares are interleaved naturally because // authored or reshared. Reshares are interleaved naturally because
// they're stored as real rows; sort by created_at gets the right order // they're stored as real rows; sort by created_at gets the right order
+14
View File
@@ -380,6 +380,20 @@ func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, post
return count, emailDue, nil return count, emailDue, nil
} }
// CreateMention persists a "you were mentioned" notification linked to the
// post. Unlike likes there's no aggregation — a mention is a deliberate,
// low-volume event.
func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, postID string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO notifications (user_id, actor_id, kind, post_id)
VALUES ($1, $2, 'post_mention', $3)`,
userID, actorID, postID,
); err != nil {
return fmt.Errorf("create mention notification: %w", err)
}
return nil
}
// LikeEmailsEnabled reports whether the user wants aggregated like emails. // LikeEmailsEnabled reports whether the user wants aggregated like emails.
func (r *NotificationRepo) LikeEmailsEnabled(ctx context.Context, userID string) (bool, error) { func (r *NotificationRepo) LikeEmailsEnabled(ctx context.Context, userID string) (bool, error) {
var enabled bool var enabled bool
+10 -2
View File
@@ -165,7 +165,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// Likes: membership in Postgres, count delta batched through Redis // Likes: membership in Postgres, count delta batched through Redis
// (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)
postSvc := service.NewPostService(postRepo, r2, likeRepo) mentionRepo := repository.NewMentionRepo(s.pg)
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo)
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo) bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2) collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
@@ -237,7 +238,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) s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo, mentionRepo)
// 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
@@ -643,6 +644,8 @@ func (s *Server) setupRouter() {
r.Get("/{id}", s.postH.Get) r.Get("/{id}", s.postH.Get)
// Paginated "liked by" list — public, like the count itself. // Paginated "liked by" list — public, like the count itself.
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).
r.Get("/{id}/mentions", s.postH.ListMentions)
}) })
// Protected — create / delete / image presign / "my posts" / // Protected — create / delete / image presign / "my posts" /
// reshare actions. // reshare actions.
@@ -662,6 +665,10 @@ func (s *Server) setupRouter() {
// count is batched into likes_count via Redis. // count is batched into likes_count via Redis.
r.Post("/{id}/like", s.postH.Like) r.Post("/{id}/like", s.postH.Like)
r.Delete("/{id}/like", s.postH.Unlike) r.Delete("/{id}/like", s.postH.Unlike)
// Mentions: composer suggestions + author add/remove of tags.
r.Get("/mention-suggest", s.postH.MentionSuggest)
r.Post("/{id}/mentions", s.postH.AddMention)
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)
}) })
@@ -725,6 +732,7 @@ func (s *Server) setupRouter() {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{handle}", s.profileH.Get) r.Get("/{handle}", s.profileH.Get)
r.Get("/{handle}/feed", s.profileH.Feed) r.Get("/{handle}/feed", s.profileH.Feed)
r.Get("/{handle}/mentions", s.profileH.MentionsFeed)
r.Get("/{handle}/followers", s.profileH.Followers) r.Get("/{handle}/followers", s.profileH.Followers)
r.Get("/{handle}/following", s.profileH.Following) r.Get("/{handle}/following", s.profileH.Following)
}) })
+33
View File
@@ -32,6 +32,7 @@ const (
NotifFollowRequest = "follow_request" NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted" NotifFollowAccepted = "follow_accepted"
NotifPostLike = "post_like" NotifPostLike = "post_like"
NotifPostMention = "post_mention"
// 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.
@@ -192,6 +193,38 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
// single post — a burst of likes within the window produces one email. // single post — a burst of likes within the window produces one email.
const likeEmailThrottle = 6 * time.Hour const likeEmailThrottle = 6 * time.Hour
// NotifyPostMention tells `mentionedID` that `actorID` (the post author)
// tagged them in a post. Persisted row + realtime + Web Push; no email
// (push/in-app is enough, and a post can tag many people). Skips self-tags.
func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID, actorID, postID string) {
if mentionedID == "" || actorID == "" || mentionedID == actorID {
return
}
if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil {
s.logger.Warn("persist mention notification failed",
zap.String("recipient", mentionedID), zap.Error(err))
}
actorUser, _ := s.users.FindByID(ctx, actorID)
s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser)
if s.push == nil {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actorUser != nil {
who = actorDisplay(actorUser)
url = s.frontendURL + "/u/" + actorUser.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, mentionedID, PushPayload{
Title: "You were mentioned",
Body: who + " mentioned you in a post",
URL: url,
Icon: s.iconURL(),
Tag: NotifPostMention + ":" + 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 {
+99 -8
View File
@@ -31,14 +31,15 @@ var (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type PostService struct { type PostService struct {
posts *repository.PostRepo posts *repository.PostRepo
r2 *repository.R2 r2 *repository.R2
likes *repository.LikeRepo likes *repository.LikeRepo
notif *NotificationService // optional; set via SetNotifier mentions *repository.MentionRepo
notif *NotificationService // optional; set via SetNotifier
} }
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo) *PostService { func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo) *PostService {
return &PostService{posts: posts, r2: r2, likes: likes} return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions}
} }
// SetNotifier wires the notification seam after construction (the // SetNotifier wires the notification seam after construction (the
@@ -49,7 +50,7 @@ func (s *PostService) SetNotifier(n *NotificationService) { s.notif = n }
// the frontend to send the exact text the user selected. Source URL must be // the frontend to send the exact text the user selected. Source URL must be
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one // a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
// was provided so links can't be hijacked. // was provided so links can't be hijacked.
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) { func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs, mentionUserIDs []string) (*model.Post, error) {
caption = strings.TrimSpace(caption) caption = strings.TrimSpace(caption)
if caption == "" { if caption == "" {
return nil, errors.New("caption is required") return nil, errors.New("caption is required")
@@ -64,7 +65,19 @@ func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, so
if len(imageURLs) > 5 { if len(imageURLs) > 5 {
return nil, errors.New("a post can hold up to 5 images") return nil, errors.New("a post can hold up to 5 images")
} }
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs) p, err := s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
if err != nil {
return nil, err
}
// Tag mentioned users (best-effort, additive — a bad id never fails the
// post itself). Reflect the count on the returned post, then notify the
// tagged people (fire-and-forget on a detached context).
if len(mentionUserIDs) > 0 {
_ = s.mentions.AddMany(ctx, p.ID, mentionUserIDs)
_ = s.mentions.HydrateCounts(ctx, []*model.Post{p})
go s.notifyMentions(context.Background(), p.ID, userID, mentionUserIDs)
}
return p, nil
} }
// Get returns one post (public). `viewerID` is "" for anonymous callers; // Get returns one post (public). `viewerID` is "" for anonymous callers;
@@ -81,6 +94,9 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil { if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
return nil, err return nil, err
} }
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
return nil, err
}
return p, nil return p, nil
} }
@@ -183,6 +199,9 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil { if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
return nil, 0, err return nil, 0, err
} }
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, 0, err
}
return posts, total, nil return posts, total, nil
} }
@@ -198,6 +217,75 @@ func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, po
return s.likes.Hydrate(ctx, viewerID, ptrs) return s.likes.Hydrate(ctx, viewerID, ptrs)
} }
// hydrateMentionsSlice fills MentionsCount on a value slice.
func (s *PostService) hydrateMentionsSlice(ctx context.Context, 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.mentions.HydrateCounts(ctx, ptrs)
}
// SuggestMentions returns prioritised mention suggestions for the composer.
func (s *PostService) SuggestMentions(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
return s.mentions.Suggest(ctx, viewerID, q, limit, offset)
}
// Mentions lists the users tagged on a post.
func (s *PostService) Mentions(ctx context.Context, postID string) ([]model.UserSummary, error) {
return s.mentions.ListForPost(ctx, postID)
}
// AddMention / RemoveMention edit a post's tags — author only.
func (s *PostService) AddMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
if err := s.mentions.Add(ctx, postID, targetUserID); err != nil {
return err
}
if s.notif != nil {
go s.notif.NotifyPostMention(context.Background(), targetUserID, viewerID, postID)
}
return nil
}
// notifyMentions notifies each freshly-tagged user (deduped, never the author).
func (s *PostService) notifyMentions(ctx context.Context, postID, authorID string, userIDs []string) {
if s.notif == nil {
return
}
seen := make(map[string]bool, len(userIDs))
for _, uid := range userIDs {
if uid == "" || uid == authorID || seen[uid] {
continue
}
seen[uid] = true
s.notif.NotifyPostMention(ctx, uid, authorID, postID)
}
}
func (s *PostService) RemoveMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
return s.mentions.Remove(ctx, postID, targetUserID)
}
func (s *PostService) requireAuthor(ctx context.Context, viewerID, postID string) error {
p, err := s.posts.FindByID(ctx, postID)
if err != nil {
return err
}
if p.UserID != viewerID {
return ErrForbidden
}
return nil
}
// Like / Unlike toggle the caller's like on a post and return the effective // Like / Unlike toggle the caller's like on a post and return the effective
// total (likes_count + pending Redis delta). // total (likes_count + pending Redis delta).
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) { func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
@@ -256,6 +344,9 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil { if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
return nil, 0, err return nil, 0, err
} }
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, 0, err
}
return posts, total, nil return posts, total, nil
} }
@@ -0,0 +1 @@
DROP TABLE IF EXISTS post_mentions;
@@ -0,0 +1,14 @@
-- Post mentions — users tagged on a post (separate from the locked caption).
-- Membership table; the count is small and edited rarely, so it's derived at
-- read time (no denormalized counter / worker needed).
CREATE TABLE IF NOT EXISTS post_mentions (
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,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (post_id, user_id)
);
-- List a post's mentions (insertion order) + hydrate mention counts across a feed.
CREATE INDEX IF NOT EXISTS post_mentions_post_idx ON post_mentions (post_id, created_at);
CREATE INDEX IF NOT EXISTS post_mentions_user_idx ON post_mentions (user_id);