veza/veza-backend-api/internal/core/social/service.go
senke 1318a53a64
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
chore(release): v0.931 — Cursor (cursor-based pagination, performance baseline)
2026-03-02 12:35:49 +01:00

428 lines
13 KiB
Go

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
}