feat: add post mention
This commit is contained in:
+103
-24
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user