veza/veza-backend-api/internal/services/social_service.go

362 lines
9.1 KiB
Go

package services
import (
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
"veza-backend-api/internal/database"
"go.uber.org/zap"
)
// SocialService handles social features (follows, likes, comments)
type SocialService struct {
db *database.Database
logger *zap.Logger
}
// Comment represents a comment on a track
type Comment struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
TrackID uuid.UUID `json:"track_id" db:"track_id"`
ParentID *uuid.UUID `json:"parent_id" db:"parent_id"`
Content string `json:"content" db:"content"`
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// NewSocialService creates a new social service
func NewSocialService(db *database.Database, logger *zap.Logger) *SocialService {
return &SocialService{
db: db,
logger: logger,
}
}
// FollowUser creates a follow relationship
func (ss *SocialService) FollowUser(ctx context.Context, followerID, followedID uuid.UUID) error {
_, err := ss.db.ExecContext(ctx, `
INSERT INTO follows (follower_id, followed_id)
VALUES ($1, $2)
ON CONFLICT (follower_id, followed_id) DO NOTHING
`, followerID, followedID)
if err != nil {
return fmt.Errorf("failed to follow user: %w", err)
}
ss.logger.Info("User followed",
zap.String("follower_id", followerID.String()),
zap.String("followed_id", followedID.String()),
)
return nil
}
// UnfollowUser removes a follow relationship
func (ss *SocialService) UnfollowUser(ctx context.Context, followerID, followedID uuid.UUID) error {
_, err := ss.db.ExecContext(ctx, `
DELETE FROM follows
WHERE follower_id = $1 AND followed_id = $2
`, followerID, followedID)
if err != nil {
return fmt.Errorf("failed to unfollow user: %w", err)
}
return nil
}
// LikeTrack creates a like on a track
func (ss *SocialService) LikeTrack(userID, trackID uuid.UUID) error {
ctx := context.Background()
_, err := ss.db.ExecContext(ctx, `
INSERT INTO likes (user_id, track_id)
VALUES ($1, $2)
ON CONFLICT (user_id, track_id) DO NOTHING
`, userID, trackID)
if err != nil {
return fmt.Errorf("failed to like track: %w", err)
}
return nil
}
// UnlikeTrack removes a like from a track
func (ss *SocialService) UnlikeTrack(userID, trackID uuid.UUID) error {
ctx := context.Background()
_, err := ss.db.ExecContext(ctx, `
DELETE FROM likes
WHERE user_id = $1 AND track_id = $2
`, userID, trackID)
if err != nil {
return fmt.Errorf("failed to unlike track: %w", err)
}
return nil
}
// CreateComment creates a comment on a track
func (ss *SocialService) CreateComment(userID, trackID uuid.UUID, content string, parentID *uuid.UUID) (*Comment, error) {
ctx := context.Background()
var commentID uuid.UUID
err := ss.db.QueryRowContext(ctx, `
INSERT INTO comments (id, user_id, track_id, parent_id, content)
VALUES (gen_random_uuid(), $1, $2, $3, $4)
RETURNING id
`, userID, trackID, parentID, content).Scan(&commentID)
if err != nil {
return nil, fmt.Errorf("failed to create comment: %w", err)
}
// Fetch and return the created comment
var comment Comment
err = ss.db.QueryRowContext(ctx, `
SELECT id, user_id, track_id, parent_id, content, created_at, updated_at
FROM comments
WHERE id = $1
`, commentID).Scan(
&comment.ID,
&comment.UserID,
&comment.TrackID,
&comment.ParentID,
&comment.Content,
&comment.CreatedAt,
&comment.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to fetch comment: %w", err)
}
return &comment, nil
}
// GetFollowersCount returns the number of followers for a user
func (ss *SocialService) GetFollowersCount(userID uuid.UUID) (int, error) {
ctx := context.Background()
var count int
err := ss.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM follows
WHERE followed_id = $1
`, userID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to get followers count: %w", err)
}
return count, nil
}
// GetFollowingCount returns the number of users being followed
func (ss *SocialService) GetFollowingCount(userID uuid.UUID) (int, error) {
ctx := context.Background()
var count int
err := ss.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM follows
WHERE follower_id = $1
`, userID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to get following count: %w", err)
}
return count, nil
}
// GetLikesCount returns the number of likes for a track
func (ss *SocialService) GetLikesCount(trackID uuid.UUID) (int, error) {
ctx := context.Background()
var count int
err := ss.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM likes
WHERE track_id = $1
`, trackID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to get likes count: %w", err)
}
return count, nil
}
// IsFollowing checks if a user is following another user
func (ss *SocialService) IsFollowing(followerID, followedID uuid.UUID) (bool, error) {
ctx := context.Background()
var exists bool
err := ss.db.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM follows
WHERE follower_id = $1 AND followed_id = $2
)
`, followerID, followedID).Scan(&exists)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("failed to check follow status: %w", err)
}
return exists, nil
}
// IsTrackLiked checks if a user has liked a track
func (ss *SocialService) IsTrackLiked(userID, trackID uuid.UUID) (bool, error) {
ctx := context.Background()
var exists bool
err := ss.db.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM likes
WHERE user_id = $1 AND track_id = $2
)
`, userID, trackID).Scan(&exists)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("failed to check like status: %w", err)
}
return exists, nil
}
// BlockUser creates a block relationship between users
// BE-API-018: Implement user block/unblock endpoints
func (ss *SocialService) BlockUser(blockerID, blockedID uuid.UUID) error {
ctx := context.Background()
// Vérifier qu'on ne peut pas se bloquer soi-même
if blockerID == blockedID {
return fmt.Errorf("cannot block yourself")
}
_, err := ss.db.ExecContext(ctx, `
INSERT INTO user_blocks (blocker_id, blocked_id)
VALUES ($1, $2)
ON CONFLICT (blocker_id, blocked_id) DO NOTHING
`, blockerID, blockedID)
if err != nil {
return fmt.Errorf("failed to block user: %w", err)
}
ss.logger.Info("User blocked",
zap.String("blocker_id", blockerID.String()),
zap.String("blocked_id", blockedID.String()),
)
return nil
}
// UnblockUser removes a block relationship between users
// BE-API-018: Implement user block/unblock endpoints
func (ss *SocialService) UnblockUser(blockerID, blockedID uuid.UUID) error {
ctx := context.Background()
_, err := ss.db.ExecContext(ctx, `
DELETE FROM user_blocks
WHERE blocker_id = $1 AND blocked_id = $2
`, blockerID, blockedID)
if err != nil {
return fmt.Errorf("failed to unblock user: %w", err)
}
ss.logger.Info("User unblocked",
zap.String("blocker_id", blockerID.String()),
zap.String("blocked_id", blockedID.String()),
)
return nil
}
// IsBlocked checks if a user is blocked by another user
// BE-API-018: Helper method to check block status
func (ss *SocialService) IsBlocked(blockerID, blockedID uuid.UUID) (bool, error) {
ctx := context.Background()
var exists bool
err := ss.db.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM user_blocks
WHERE blocker_id = $1 AND blocked_id = $2
)
`, blockerID, blockedID).Scan(&exists)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("failed to check block status: %w", err)
}
return exists, nil
}
// SuggestionUser is a minimal user for follow suggestions (v0.10.0 F211)
type SuggestionUser struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
FollowersCount int `json:"followers_count"`
}
// GetFollowSuggestions returns users to follow based on "friends of friends" (v0.10.0 F211).
// No ML - simple 2-hop: users followed by people the current user follows.
func (ss *SocialService) GetFollowSuggestions(ctx context.Context, userID uuid.UUID, limit int) ([]SuggestionUser, error) {
if limit <= 0 {
limit = 10
}
if limit > 20 {
limit = 20
}
rows, err := ss.db.QueryContext(ctx, `
SELECT DISTINCT u.id, u.username, COALESCE(u.avatar, '') as avatar,
COALESCE(up.follower_count, 0) as follower_count
FROM follows f1
JOIN follows f2 ON f2.follower_id = f1.followed_id
JOIN users u ON u.id = f2.followed_id AND u.deleted_at IS NULL
LEFT JOIN user_profiles up ON up.user_id = u.id
WHERE f1.follower_id = $1
AND f2.followed_id != $1
AND f2.followed_id NOT IN (SELECT followed_id FROM follows WHERE follower_id = $1)
LIMIT $2
`, userID, userID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get follow suggestions: %w", err)
}
defer rows.Close()
var result []SuggestionUser
for rows.Next() {
var u SuggestionUser
var avatar string
if err := rows.Scan(&u.ID, &u.Username, &avatar, &u.FollowersCount); err != nil {
continue
}
u.AvatarURL = avatar
result = append(result, u)
}
return result, nil
}