Files
spsays-bff/internal/repository/social_graph_repo.go
T

309 lines
11 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// social_graph_repo.go bundles the four small repos backing the social
// graph — follows, blocks, reports, and notifications. Each is a thin
// wrapper over the shared Postgres pool, mirroring the per-domain repo
// convention used elsewhere (UserRepo, PostRepo, …). They're grouped in
// one file because each is only a handful of queries and they share the
// same dependency.
// ---------------------------------------------------------------------------
// FollowRepo
// ---------------------------------------------------------------------------
// FollowRepo manages the request-based follows edge table.
type FollowRepo struct{ pg *Postgres }
func NewFollowRepo(pg *Postgres) *FollowRepo { return &FollowRepo{pg: pg} }
// Request inserts a pending follow edge follower→following. Idempotent:
// a repeated request (the edge already exists, pending or accepted) is a
// no-op. Returns created=true only when a brand-new pending row was
// inserted, so the caller fires the "X requested to follow you"
// notification exactly once.
func (r *FollowRepo) Request(ctx context.Context, followerID, followingID string) (created bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO follows (follower_id, following_id, status)
VALUES ($1, $2, 'pending')
ON CONFLICT (follower_id, following_id) DO NOTHING`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("request follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Accept flips a pending edge follower→following to accepted and stamps
// responded_at. Returns accepted=true only on the transition from
// pending (so the "X accepted your request" notification fires once); a
// call on an already-accepted or absent edge returns false.
func (r *FollowRepo) Accept(ctx context.Context, followerID, followingID string) (accepted bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE follows
SET status = 'accepted', responded_at = now()
WHERE follower_id = $1 AND following_id = $2 AND status = 'pending'`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("accept follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Delete removes the edge follower→following regardless of status. Backs
// unfollow (accepted), cancel-request (own pending outgoing), reject
// (their pending incoming), and remove-follower (their accepted edge to
// me). Idempotent.
func (r *FollowRepo) Delete(ctx context.Context, followerID, followingID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
)
if err != nil {
return fmt.Errorf("delete follow: %w", err)
}
return nil
}
// Status returns the status of the edge follower→following ('pending' or
// 'accepted'), or "" when no edge exists.
func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) (string, error) {
var status string
err := r.pg.Pool.QueryRow(ctx, `
SELECT status FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
).Scan(&status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("follow status: %w", err)
}
return status, nil
}
// CountFollowers counts accepted edges pointing at userID.
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE following_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count followers: %w", err)
}
return n, nil
}
// CountFollowing counts accepted edges originating from userID.
func (r *FollowRepo) CountFollowing(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE follower_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count following: %w", err)
}
return n, nil
}
// ---------------------------------------------------------------------------
// BlockRepo
// ---------------------------------------------------------------------------
// BlockRepo manages user_blocks. Block() also tears down any follow
// edges in either direction, so it runs in a transaction.
type BlockRepo struct{ pg *Postgres }
func NewBlockRepo(pg *Postgres) *BlockRepo { return &BlockRepo{pg: pg} }
// Block records blocker→blocked (idempotent) and, in the same tx, deletes
// any follow edges between the two in either direction — blocking someone
// should sever the relationship both ways.
func (r *BlockRepo) Block(ctx context.Context, blockerID, blockedID string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin block tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO user_blocks (blocker_id, blocked_id)
VALUES ($1, $2)
ON CONFLICT (blocker_id, blocked_id) DO NOTHING`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("insert block: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM follows
WHERE (follower_id = $1 AND following_id = $2)
OR (follower_id = $2 AND following_id = $1)`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("sever follows on block: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit block: %w", err)
}
return nil
}
// Unblock removes the blocker→blocked record. Idempotent.
func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2`,
blockerID, blockedID,
)
if err != nil {
return fmt.Errorf("unblock: %w", err)
}
return nil
}
// BlockState reports, in one round-trip, whether a blocked b and whether
// b blocked a.
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
err = r.pg.Pool.QueryRow(ctx, `
SELECT
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2),
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $2 AND blocked_id = $1)`,
a, b,
).Scan(&aBlockedB, &bBlockedA)
if err != nil {
return false, false, fmt.Errorf("block state: %w", err)
}
return aBlockedB, bBlockedA, nil
}
// ---------------------------------------------------------------------------
// ReportRepo
// ---------------------------------------------------------------------------
// ReportRepo manages user_reports.
type ReportRepo struct{ pg *Postgres }
func NewReportRepo(pg *Postgres) *ReportRepo { return &ReportRepo{pg: pg} }
// Create inserts a user-report row.
func (r *ReportRepo) Create(ctx context.Context, reporterID, reportedID, category, reason string) (*model.UserReport, error) {
rep := &model.UserReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO user_reports (reporter_id, reported_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, reported_id, category, reason, created_at, resolved_at`,
reporterID, reportedID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.ReportedID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create report: %w", err)
}
return rep, nil
}
// CreatePostReport inserts a post-report row.
func (r *ReportRepo) CreatePostReport(ctx context.Context, reporterID, postID, category, reason string) (*model.PostReport, error) {
rep := &model.PostReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO post_reports (reporter_id, post_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, post_id, category, reason, created_at, resolved_at`,
reporterID, postID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.PostID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create post report: %w", err)
}
return rep, nil
}
// ---------------------------------------------------------------------------
// NotificationRepo
// ---------------------------------------------------------------------------
// NotificationRepo persists notification rows. Reads (list / unread
// count / mark-read) will be added when the in-app notification center
// ships; for now the table is write-only, feeding the email fan-out and
// standing ready for websocket/push.
type NotificationRepo struct{ pg *Postgres }
func NewNotificationRepo(pg *Postgres) *NotificationRepo { return &NotificationRepo{pg: pg} }
// Create inserts a notification for recipient userID, triggered by
// actorID (nil for system notifications), of the given kind.
func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *string, kind string) (*model.Notification, error) {
n := &model.Notification{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind)
VALUES ($1, $2, $3)
RETURNING id, user_id, actor_id, kind, created_at, read_at`,
userID, actorID, kind,
).Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt)
if err != nil {
return nil, fmt.Errorf("create notification: %w", err)
}
return n, nil
}
// ListByUser returns a recipient's notifications, newest first, with the
// actor's display fields joined in for rendering (nil for system
// notifications or since-deleted actors).
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT n.id, n.user_id, n.actor_id, n.kind, n.created_at, n.read_at,
u.name, u.handle, u.avatar_url
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = $1
ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
out := []model.Notification{}
for rows.Next() {
var n model.Notification
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt,
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
// CountUnread returns how many of a recipient's notifications are unread.
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
}
return n, nil
}
// MarkAllRead stamps read_at on every still-unread notification for the
// user. Idempotent.
func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID string) error {
_, err := r.pg.Pool.Exec(ctx,
`UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL`, userID,
)
if err != nil {
return fmt.Errorf("mark notifications read: %w", err)
}
return nil
}