diff --git a/internal/handler/social.go b/internal/handler/social.go index 090e0bc..9def979 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -59,10 +59,11 @@ func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) { } type createPostReq struct { - Caption string `json:"caption"` - SourceURL string `json:"source_url"` - SourceTitle string `json:"source_title"` - ImageURLs []string `json:"image_urls"` + Caption string `json:"caption"` + 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 - } + ptrs := make([]*model.Post, len(posts)) + for i := range posts { + ptrs[i] = &posts[i] } - 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: diff --git a/internal/model/social.go b/internal/model/social.go index 1420fc8..1a1c552 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -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. diff --git a/internal/model/user.go b/internal/model/user.go index fb6a162..d747306 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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"` diff --git a/internal/repository/mention_repo.go b/internal/repository/mention_repo.go new file mode 100644 index 0000000..9876848 --- /dev/null +++ b/internal/repository/mention_repo.go @@ -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 +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index cb67888..33dae9a 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -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 diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index f5eb076..4cd6f6c 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index a3eb4d4..f7b3e39 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) }) diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index efbcd81..cd5921b 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -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 { diff --git a/internal/service/social_service.go b/internal/service/social_service.go index f5cd705..fdf4327 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -31,14 +31,15 @@ var ( // --------------------------------------------------------------------------- type PostService struct { - posts *repository.PostRepo - r2 *repository.R2 - likes *repository.LikeRepo - notif *NotificationService // optional; set via SetNotifier + 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 } diff --git a/migrations/0033_create_post_mentions.down.sql b/migrations/0033_create_post_mentions.down.sql new file mode 100644 index 0000000..4bc8a64 --- /dev/null +++ b/migrations/0033_create_post_mentions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS post_mentions; diff --git a/migrations/0033_create_post_mentions.up.sql b/migrations/0033_create_post_mentions.up.sql new file mode 100644 index 0000000..c96e694 --- /dev/null +++ b/migrations/0033_create_post_mentions.up.sql @@ -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);