306 lines
8.6 KiB
Go
306 lines
8.6 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// likeDeltaPrefix namespaces the per-post pending-like counters in Redis. Each
|
|
// like/unlike INCRBY/DECRBYs likes:delta:{postID}; the LikesWorker folds these
|
|
// into posts.likes_count every 15 minutes (GETDEL + UPDATE). Reads add the
|
|
// still-pending delta on top of likes_count so the displayed total is current.
|
|
const likeDeltaPrefix = "likes:delta:"
|
|
|
|
// LikeRepo handles post-like membership (Postgres) and the write-behind count
|
|
// delta (Redis). Membership is written synchronously so viewer state and the
|
|
// "liked by" list stay consistent; only the hot counter is batched.
|
|
//
|
|
// `redis` is optional: when nil (Redis unavailable) likes still work, but the
|
|
// count is applied to posts.likes_count directly on each like instead of being
|
|
// batched.
|
|
type LikeRepo struct {
|
|
pg *Postgres
|
|
redis *Redis
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func NewLikeRepo(pg *Postgres, redis *Redis, logger *zap.Logger) *LikeRepo {
|
|
return &LikeRepo{pg: pg, redis: redis, logger: logger}
|
|
}
|
|
|
|
func (r *LikeRepo) deltaKey(postID string) string { return likeDeltaPrefix + postID }
|
|
|
|
// Like records a membership row. Returns whether it was newly liked (false if
|
|
// the user had already liked it — idempotent). On a real change it nudges the
|
|
// pending count up.
|
|
func (r *LikeRepo) Like(ctx context.Context, postID, userID string) (bool, error) {
|
|
tag, err := r.pg.Pool.Exec(ctx,
|
|
`INSERT INTO post_likes (post_id, user_id) VALUES ($1, $2)
|
|
ON CONFLICT (post_id, user_id) DO NOTHING`,
|
|
postID, userID,
|
|
)
|
|
if err != nil {
|
|
return false, fmt.Errorf("insert like: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return false, nil // already liked
|
|
}
|
|
r.bumpDelta(ctx, postID, 1)
|
|
return true, nil
|
|
}
|
|
|
|
// Unlike removes the membership row. Returns whether it actually changed.
|
|
func (r *LikeRepo) Unlike(ctx context.Context, postID, userID string) (bool, error) {
|
|
tag, err := r.pg.Pool.Exec(ctx,
|
|
`DELETE FROM post_likes WHERE post_id = $1 AND user_id = $2`,
|
|
postID, userID,
|
|
)
|
|
if err != nil {
|
|
return false, fmt.Errorf("delete like: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return false, nil // wasn't liked
|
|
}
|
|
r.bumpDelta(ctx, postID, -1)
|
|
return true, nil
|
|
}
|
|
|
|
// bumpDelta adjusts the pending count. Prefers the Redis write-behind counter;
|
|
// falls back to a direct DB update when Redis is unavailable so the count is
|
|
// never lost.
|
|
func (r *LikeRepo) bumpDelta(ctx context.Context, postID string, d int64) {
|
|
if r.redis != nil {
|
|
if err := r.redis.Client.IncrBy(ctx, r.deltaKey(postID), d).Err(); err == nil {
|
|
return
|
|
}
|
|
r.logger.Warn("likes: redis incr failed, applying to DB", zap.String("post", postID))
|
|
}
|
|
if _, err := r.pg.Pool.Exec(ctx,
|
|
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
|
|
postID, d,
|
|
); err != nil {
|
|
r.logger.Warn("likes: direct count update failed", zap.String("post", postID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// PostAuthor returns the user id that owns the post (for like notifications).
|
|
func (r *LikeRepo) PostAuthor(ctx context.Context, postID string) (string, error) {
|
|
var uid string
|
|
if err := r.pg.Pool.QueryRow(ctx,
|
|
`SELECT user_id FROM posts WHERE id = $1`, postID,
|
|
).Scan(&uid); err != nil {
|
|
return "", fmt.Errorf("post author: %w", err)
|
|
}
|
|
return uid, nil
|
|
}
|
|
|
|
// EffectiveCount returns likes_count + the pending Redis delta for one post.
|
|
func (r *LikeRepo) EffectiveCount(ctx context.Context, postID string) (int, error) {
|
|
var c int
|
|
if err := r.pg.Pool.QueryRow(ctx,
|
|
`SELECT likes_count FROM posts WHERE id = $1`, postID,
|
|
).Scan(&c); err != nil {
|
|
return 0, fmt.Errorf("read likes_count: %w", err)
|
|
}
|
|
if r.redis != nil {
|
|
if d, err := r.redis.Client.Get(ctx, r.deltaKey(postID)).Int(); err == nil {
|
|
c += d
|
|
}
|
|
}
|
|
if c < 0 {
|
|
c = 0
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// Hydrate fills LikesCount (effective) and ViewerHasLiked on the given posts.
|
|
// Used by every post read path. viewerID "" leaves ViewerHasLiked false.
|
|
func (r *LikeRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error {
|
|
ids := make([]string, 0, len(posts))
|
|
for _, p := range posts {
|
|
if p != nil {
|
|
ids = append(ids, p.ID)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Denormalized counts from Postgres.
|
|
counts := make(map[string]int, len(ids))
|
|
rows, err := r.pg.Pool.Query(ctx,
|
|
`SELECT id, likes_count FROM posts WHERE id = ANY($1)`, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("hydrate like counts: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var id string
|
|
var c int
|
|
if err := rows.Scan(&id, &c); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("scan like count: %w", err)
|
|
}
|
|
counts[id] = c
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Pending deltas from Redis (best-effort).
|
|
deltas := r.pendingDeltas(ctx, ids)
|
|
|
|
// Viewer membership.
|
|
liked := make(map[string]bool)
|
|
if viewerID != "" {
|
|
lr, err := r.pg.Pool.Query(ctx,
|
|
`SELECT post_id FROM post_likes WHERE user_id = $1 AND post_id = ANY($2)`,
|
|
viewerID, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("hydrate viewer likes: %w", err)
|
|
}
|
|
for lr.Next() {
|
|
var pid string
|
|
if err := lr.Scan(&pid); err != nil {
|
|
lr.Close()
|
|
return fmt.Errorf("scan viewer like: %w", err)
|
|
}
|
|
liked[pid] = true
|
|
}
|
|
lr.Close()
|
|
if err := lr.Err(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, p := range posts {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
c := counts[p.ID] + deltas[p.ID]
|
|
if c < 0 {
|
|
c = 0
|
|
}
|
|
p.LikesCount = c
|
|
p.ViewerHasLiked = liked[p.ID]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pendingDeltas returns the Redis like-deltas for the given posts (absent →
|
|
// zero). Best-effort: Redis errors yield an empty map.
|
|
func (r *LikeRepo) pendingDeltas(ctx context.Context, ids []string) map[string]int {
|
|
out := make(map[string]int)
|
|
if r.redis == nil || len(ids) == 0 {
|
|
return out
|
|
}
|
|
keys := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
keys[i] = r.deltaKey(id)
|
|
}
|
|
vals, err := r.redis.Client.MGet(ctx, keys...).Result()
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for i, v := range vals {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if n, err := strconv.Atoi(s); err == nil && n != 0 {
|
|
out[ids[i]] = n
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListLikers returns one page of users who liked the post (newest first),
|
|
// optionally filtered by a name/handle search, plus the total matching count.
|
|
func (r *LikeRepo) ListLikers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
|
like := "%" + q + "%"
|
|
|
|
var total int
|
|
if err := r.pg.Pool.QueryRow(ctx, `
|
|
SELECT count(*)
|
|
FROM post_likes l JOIN users u ON u.id = l.user_id
|
|
WHERE l.post_id = $1
|
|
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`,
|
|
postID, q, like,
|
|
).Scan(&total); err != nil {
|
|
return nil, 0, fmt.Errorf("count likers: %w", err)
|
|
}
|
|
if total == 0 {
|
|
return []model.UserSummary{}, 0, nil
|
|
}
|
|
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT u.handle, u.name, u.avatar_url
|
|
FROM post_likes l JOIN users u ON u.id = l.user_id
|
|
WHERE l.post_id = $1
|
|
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
|
ORDER BY l.created_at DESC, u.handle ASC
|
|
LIMIT $4 OFFSET $5`,
|
|
postID, q, like, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("list likers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]model.UserSummary, 0, limit)
|
|
for rows.Next() {
|
|
var s model.UserSummary
|
|
if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil {
|
|
return nil, 0, fmt.Errorf("scan liker: %w", err)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
// FlushDeltas drains every pending Redis like-delta into posts.likes_count.
|
|
// Called periodically by the LikesWorker. Returns how many posts were updated.
|
|
// GETDEL makes each pop atomic; a failed UPDATE restores the delta so it's
|
|
// retried next cycle.
|
|
func (r *LikeRepo) FlushDeltas(ctx context.Context) (int, error) {
|
|
if r.redis == nil {
|
|
return 0, nil
|
|
}
|
|
var cursor uint64
|
|
applied := 0
|
|
for {
|
|
keys, next, err := r.redis.Client.Scan(ctx, cursor, likeDeltaPrefix+"*", 200).Result()
|
|
if err != nil {
|
|
return applied, fmt.Errorf("scan like deltas: %w", err)
|
|
}
|
|
for _, key := range keys {
|
|
postID := strings.TrimPrefix(key, likeDeltaPrefix)
|
|
v, err := r.redis.Client.GetDel(ctx, key).Int()
|
|
if err != nil || v == 0 {
|
|
continue
|
|
}
|
|
if _, err := r.pg.Pool.Exec(ctx,
|
|
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
|
|
postID, v,
|
|
); err != nil {
|
|
// Restore so the delta isn't lost; retried next cycle.
|
|
r.redis.Client.IncrBy(ctx, key, int64(v))
|
|
r.logger.Warn("likes flush: update failed, delta restored",
|
|
zap.String("post", postID), zap.Error(err))
|
|
continue
|
|
}
|
|
applied++
|
|
}
|
|
cursor = next
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
return applied, nil
|
|
}
|