feat: add post mention
This commit is contained in:
+94
-15
@@ -63,6 +63,7 @@ type createPostReq struct {
|
||||
SourceURL string `json:"source_url"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
MentionUserIDs []string `json:"mention_user_ids"`
|
||||
}
|
||||
|
||||
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())
|
||||
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 {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
@@ -87,6 +88,60 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// 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
|
||||
@@ -489,10 +544,11 @@ type ProfileHandler struct {
|
||||
collections *repository.CollectionRepo
|
||||
graph *service.SocialGraphService
|
||||
likes *repository.LikeRepo
|
||||
mentions *repository.MentionRepo
|
||||
}
|
||||
|
||||
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo) *ProfileHandler {
|
||||
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes}
|
||||
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 (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)
|
||||
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())
|
||||
if viewerID != "" && len(posts) > 0 {
|
||||
if viewerID != "" {
|
||||
ids := make([]string, 0, len(posts))
|
||||
for i := range posts {
|
||||
// Skip own posts — can't reshare yourself, and the swap
|
||||
// already triggers on isOwner.
|
||||
if posts[i].UserID != viewerID {
|
||||
if posts[i].UserID != viewerID { // can't reshare your own post
|
||||
ids = append(ids, posts[i].ID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
for i := range posts {
|
||||
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.
|
||||
if len(posts) > 0 {
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||
return h.mentions.HydrateCounts(r.Context(), ptrs)
|
||||
}
|
||||
|
||||
// followListResp is the payload for the followers / following list endpoints:
|
||||
|
||||
@@ -46,6 +46,10 @@ type Post struct {
|
||||
// false for anonymous viewers.
|
||||
LikesCount int `json:"likes_count"`
|
||||
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.
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
// UserSummary is the lightweight projection used in follower / following
|
||||
// lists — just enough to render a row and link to the profile.
|
||||
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"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -171,6 +171,32 @@ func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offse
|
||||
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,
|
||||
// authored or reshared. Reshares are interleaved naturally because
|
||||
// they're stored as real rows; sort by created_at gets the right order
|
||||
|
||||
@@ -380,6 +380,20 @@ func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, post
|
||||
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.
|
||||
func (r *NotificationRepo) LikeEmailsEnabled(ctx context.Context, userID string) (bool, error) {
|
||||
var enabled bool
|
||||
|
||||
@@ -165,7 +165,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
// Likes: membership in Postgres, count delta batched through Redis
|
||||
// (s.redis may be nil — LikeRepo then applies counts directly).
|
||||
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)
|
||||
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.notificationH = handler.NewNotificationHandler(notifSvc)
|
||||
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
|
||||
// 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)
|
||||
// Paginated "liked by" list — public, like the count itself.
|
||||
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" /
|
||||
// reshare actions.
|
||||
@@ -662,6 +665,10 @@ func (s *Server) setupRouter() {
|
||||
// count is batched into likes_count via Redis.
|
||||
r.Post("/{id}/like", s.postH.Like)
|
||||
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.
|
||||
r.Post("/{id}/report", s.postH.Report)
|
||||
})
|
||||
@@ -725,6 +732,7 @@ func (s *Server) setupRouter() {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/{handle}", s.profileH.Get)
|
||||
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||
r.Get("/{handle}/mentions", s.profileH.MentionsFeed)
|
||||
r.Get("/{handle}/followers", s.profileH.Followers)
|
||||
r.Get("/{handle}/following", s.profileH.Following)
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
NotifFollowRequest = "follow_request"
|
||||
NotifFollowAccepted = "follow_accepted"
|
||||
NotifPostLike = "post_like"
|
||||
NotifPostMention = "post_mention"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// 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.
|
||||
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
|
||||
// "X and N others liked your post".
|
||||
func likeMessage(who string, count int) string {
|
||||
|
||||
@@ -34,11 +34,12 @@ type PostService struct {
|
||||
posts *repository.PostRepo
|
||||
r2 *repository.R2
|
||||
likes *repository.LikeRepo
|
||||
mentions *repository.MentionRepo
|
||||
notif *NotificationService // optional; set via SetNotifier
|
||||
}
|
||||
|
||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo) *PostService {
|
||||
return &PostService{posts: posts, r2: r2, likes: likes}
|
||||
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}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
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)
|
||||
if caption == "" {
|
||||
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 {
|
||||
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;
|
||||
@@ -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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// total (likes_count + pending Redis delta).
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
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);
|
||||
Reference in New Issue
Block a user