package social import ( "context" "encoding/base64" "fmt" "strconv" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/models" "veza-backend-api/internal/services" ) // SocialService gère les interactions sociales type SocialService interface { CreatePost(ctx context.Context, userID uuid.UUID, content string, attachments map[string]uuid.UUID) (*Post, error) GetGlobalFeed(ctx context.Context, limit, offset int, feedType string, userID *uuid.UUID) ([]FeedItem, error) GetGlobalFeedWithCursor(ctx context.Context, limit int, cursor, feedType string, userID *uuid.UUID) ([]FeedItem, string, error) // v0.931: keyset cursor GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]FeedItem, error) GetPostsByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Post, error) // Interactions ToggleLike(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string) (bool, error) AddComment(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string, content string) (*Comment, error) // Internal CreateActivityPost(ctx context.Context, userID uuid.UUID, content string, meta map[string]interface{}) error // Trending (v0.203 Lot L) GetTrendingHashtags(ctx context.Context, limit int) ([]TrendingTag, error) } // Service implémente SocialService type Service struct { db *gorm.DB logger *zap.Logger cacheService *services.CacheService // optional, for trending cache } // SetCacheService définit le service de cache (optionnel, pour trending) func (s *Service) SetCacheService(cache *services.CacheService) { s.cacheService = cache } // NewService crée une nouvelle instance du service social func NewService(db *gorm.DB, logger *zap.Logger) *Service { return &Service{ db: db, logger: logger, } } // CreatePost crée une nouvelle publication func (s *Service) CreatePost(ctx context.Context, userID uuid.UUID, content string, attachments map[string]uuid.UUID) (*Post, error) { post := &Post{ UserID: userID, Content: content, Type: PostTypeStatus, } // Handle attachments if trackID, ok := attachments["track_id"]; ok { post.TrackID = &trackID post.Type = PostTypeShare } if playlistID, ok := attachments["playlist_id"]; ok { post.PlaylistID = &playlistID post.Type = PostTypeShare } if err := s.db.Create(post).Error; err != nil { s.logger.Error("Failed to create post", zap.Error(err), zap.String("user_id", userID.String())) return nil, err } return post, nil } // GetGlobalFeed récupère un flux d'activité global (S1.2, S1.6: enrichi, type filter) func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType string, userID *uuid.UUID) ([]FeedItem, error) { query := s.db.WithContext(ctx).Model(&Post{}) if feedType == "following" && userID != nil { // Posts from users that current user follows query = query.Joins("INNER JOIN follows ON follows.followed_id = posts.user_id AND follows.follower_id = ?", *userID) } else if feedType == "groups" && userID != nil { // S2.4: Posts from members of groups the current user belongs to // Subquery: user_ids that share at least one group with current user subQuery := s.db.WithContext(ctx).Model(&GroupMember{}). Select("DISTINCT gm2.user_id"). Joins("INNER JOIN group_members gm2 ON group_members.group_id = gm2.group_id AND gm2.user_id != ?", *userID). Where("group_members.user_id = ?", *userID) query = query.Where("posts.user_id IN (?)", subQuery) } var posts []Post if err := query.Order("posts.created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil { return nil, err } return s.enrichFeedFromPosts(ctx, posts) } // GetGlobalFeedWithCursor uses keyset pagination on (created_at, id) for consistent performance (v0.931). // Cursor format: base64(created_at_unix_nano|uuid). Returns feed items and next cursor. func (s *Service) GetGlobalFeedWithCursor(ctx context.Context, limit int, cursor, feedType string, userID *uuid.UUID) ([]FeedItem, string, error) { query := s.db.WithContext(ctx).Model(&Post{}) if feedType == "following" && userID != nil { query = query.Joins("INNER JOIN follows ON follows.followed_id = posts.user_id AND follows.follower_id = ?", *userID) } else if feedType == "groups" && userID != nil { subQuery := s.db.WithContext(ctx).Model(&GroupMember{}). Select("DISTINCT gm2.user_id"). Joins("INNER JOIN group_members gm2 ON group_members.group_id = gm2.group_id AND gm2.user_id != ?", *userID). Where("group_members.user_id = ?", *userID) query = query.Where("posts.user_id IN (?)", subQuery) } var cursorCreatedAt int64 var cursorID uuid.UUID if cursor != "" { decoded, err := base64.RawURLEncoding.DecodeString(cursor) if err == nil { parts := strings.SplitN(string(decoded), "|", 2) if len(parts) == 2 { if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil { cursorCreatedAt = ts } if uid, err := uuid.Parse(parts[1]); err == nil { cursorID = uid } } } } if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) { query = query.Where("(posts.created_at, posts.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID) } if limit <= 0 { limit = 20 } if limit > 50 { limit = 50 } query = query.Order("posts.created_at desc, posts.id desc").Limit(limit + 1) var posts []Post if err := query.Find(&posts).Error; err != nil { return nil, "", err } var nextCursor string if len(posts) > limit { last := posts[limit-1] nextCursor = base64.RawURLEncoding.EncodeToString([]byte( fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String()))) posts = posts[:limit] } feed, err := s.enrichFeedFromPosts(ctx, posts) if err != nil { return nil, "", err } return feed, nextCursor, nil } func (s *Service) enrichFeedFromPosts(ctx context.Context, posts []Post) ([]FeedItem, error) { var feed []FeedItem actorIDs := make(map[uuid.UUID]bool) var trackIDs []uuid.UUID for _, p := range posts { actorIDs[p.UserID] = true if p.TrackID != nil { trackIDs = append(trackIDs, *p.TrackID) } } actors := make(map[uuid.UUID]struct{ Name, Avatar string }) if len(actorIDs) > 0 { ids := make([]uuid.UUID, 0, len(actorIDs)) for id := range actorIDs { ids = append(ids, id) } var users []models.User if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err == nil { for _, u := range users { name := u.Username if u.FirstName != "" || u.LastName != "" { name = (u.FirstName + " " + u.LastName) if name = strings.TrimSpace(name); name == "" { name = u.Username } } actors[u.ID] = struct{ Name, Avatar string }{Name: name, Avatar: u.Avatar} } } } tracks := make(map[uuid.UUID]*models.Track) if len(trackIDs) > 0 { var tr []models.Track if err := s.db.WithContext(ctx).Where("id IN ?", trackIDs).Find(&tr).Error; err == nil { for i := range tr { tracks[tr[i].ID] = &tr[i] } } } for _, p := range posts { targetType := "none" targetID := uuid.Nil if p.TrackID != nil { targetType = "track" targetID = *p.TrackID } else if p.PlaylistID != nil { targetType = "playlist" targetID = *p.PlaylistID } item := FeedItem{ ID: fmt.Sprintf("post:%s", p.ID.String()), Type: ActivityPost, ActorID: p.UserID, TargetID: targetID, TargetType: targetType, Content: p.Content, CreatedAt: p.CreatedAt, } if a, ok := actors[p.UserID]; ok { item.ActorName = a.Name item.ActorAvatar = a.Avatar } if targetType == "track" { if t, ok := tracks[targetID]; ok { item.Track = &FeedItemTrack{ ID: t.ID.String(), Title: t.Title, Artist: t.Artist, CoverURL: t.CoverArtPath, Duration: formatDuration(t.Duration), Genre: t.Genre, } } } if p.Type == PostTypeActivity { item.Type = ActivityPurchase } feed = append(feed, item) } return feed, nil } func formatDuration(seconds int) string { m := seconds / 60 s := seconds % 60 return fmt.Sprintf("%d:%02d", m, s) } // GetUserFeed récupère le flux d'un utilisateur (posts from people they follow + their own) // NOTE: For now it just returns their own posts as FeedItems, keeping meaningful naming. func (s *Service) GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]FeedItem, error) { var posts []Post if err := s.db.Where("user_id = ?", userID).Order("created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil { return nil, err } var feed []FeedItem for _, p := range posts { item := FeedItem{ ID: fmt.Sprintf("post:%s", p.ID.String()), Type: ActivityPost, ActorID: p.UserID, Content: p.Content, CreatedAt: p.CreatedAt, TargetType: "user_wall", } feed = append(feed, item) } return feed, nil } // GetPostsByUser récupère les posts d'un utilisateur spécifique func (s *Service) GetPostsByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Post, error) { var posts []Post if err := s.db.Where("user_id = ?", userID).Order("created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil { return nil, err } return posts, nil } // ToggleLike ajoute ou supprime un like // Transactionnelle : SELECT like + DELETE/CREATE + UPDATE compteur dans une seule transaction func (s *Service) ToggleLike(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string) (bool, error) { var liked bool err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. VÉRIFICATION : Like existe déjà ? (SELECT dans la transaction) var like Like err := tx.Where("user_id = ? AND target_id = ? AND target_type = ?", userID, targetID, targetType).First(&like).Error if err == nil { // 2a. Mode UNLIKE : Like existe, on le supprime if err := tx.Delete(&like).Error; err != nil { return fmt.Errorf("ToggleLike: failed to delete like: %w", err) } // 3a. Décrémenter le compteur si c'est un post (dans la transaction) if targetType == "post" { if err := tx.Model(&Post{}).Where("id = ?", targetID).Update("like_count", gorm.Expr("like_count - 1")).Error; err != nil { return fmt.Errorf("ToggleLike: failed to decrement like_count: %w", err) } } liked = false return nil } else if err == gorm.ErrRecordNotFound { // 2b. Mode LIKE : Like n'existe pas, on le crée // Vérifier d'abord que la ressource existe (pour les posts) if targetType == "post" { var post Post if err := tx.Where("id = ?", targetID).First(&post).Error; err != nil { if err == gorm.ErrRecordNotFound { return fmt.Errorf("ToggleLike: post not found: %w", err) } return fmt.Errorf("ToggleLike: failed to check post existence: %w", err) } } like = Like{ UserID: userID, TargetID: targetID, TargetType: targetType, } if err := tx.Create(&like).Error; err != nil { return fmt.Errorf("ToggleLike: failed to create like: %w", err) } // 3b. Incrémenter le compteur si c'est un post (dans la transaction) if targetType == "post" { if err := tx.Model(&Post{}).Where("id = ?", targetID).Update("like_count", gorm.Expr("like_count + 1")).Error; err != nil { return fmt.Errorf("ToggleLike: failed to increment like_count: %w", err) } } liked = true return nil } else { return fmt.Errorf("ToggleLike: failed to check like existence: %w", err) } }) if err != nil { return false, err // Rollback automatique si erreur } return liked, nil } // AddComment ajoute un commentaire // Transactionnelle : CREATE comment + UPDATE compteur dans une seule transaction func (s *Service) AddComment(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string, content string) (*Comment, error) { var comment *Comment err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. VALIDATION : Post existe ? (SELECT dans la transaction si targetType == "post") if targetType == "post" { var post Post if err := tx.First(&post, "id = ?", targetID).Error; err != nil { if err == gorm.ErrRecordNotFound { return fmt.Errorf("post not found") } return fmt.Errorf("AddComment: failed to validate post: %w", err) } } // 2. CRÉATION : Commentaire (INSERT dans la transaction) comment = &Comment{ UserID: userID, TargetID: targetID, TargetType: targetType, Content: content, } if err := tx.Create(comment).Error; err != nil { return fmt.Errorf("AddComment: failed to create comment: %w", err) } // 3. MISE À JOUR : Compteur (UPDATE dans la transaction) if targetType == "post" { if err := tx.Model(&Post{}).Where("id = ?", targetID).Update("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil { return fmt.Errorf("AddComment: failed to increment comment_count: %w", err) } } // 4. RETOUR nil = commit automatique return nil }) if err != nil { return nil, err // Rollback automatique si erreur } return comment, nil } // CreateActivityPost crée un post automatique pour une activité (ex: Achat) func (s *Service) CreateActivityPost(ctx context.Context, userID uuid.UUID, content string, meta map[string]interface{}) error { post := &Post{ UserID: userID, Content: content, Type: PostTypeActivity, } if trackIDStr, ok := meta["track_id"].(string); ok { if trackID, err := uuid.Parse(trackIDStr); err == nil { post.TrackID = &trackID } } return s.db.Create(post).Error }