diff --git a/VERSION b/VERSION index c7a29c710..9c0c75c60 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.922 +0.931 diff --git a/docs/PERFORMANCE_BASELINE.md b/docs/PERFORMANCE_BASELINE.md new file mode 100644 index 000000000..baca017d1 --- /dev/null +++ b/docs/PERFORMANCE_BASELINE.md @@ -0,0 +1,41 @@ +# Performance Baseline — Veza API + +**Version** : v0.931 +**Objectif** : Documenter les latences P50/P95/P99 des endpoints critiques pour détecter les régressions. + +## Méthodologie + +1. Démarrer l'API en mode profiling : `pprof` est exposé si `ENABLE_PPROF=true` +2. Exécuter un load test (k6 ou Go) sur les endpoints critiques +3. Mesurer latences via Prometheus (`http_request_duration_seconds`) ou pprof + +## Endpoints critiques à monitorer + +| Endpoint | Méthode | Description | +|----------|---------|-------------| +| `/api/v1/auth/login` | POST | Login utilisateur | +| `/api/v1/auth/register` | POST | Inscription | +| `/api/v1/tracks` | GET | Liste des tracks (cursor pagination v0.931) | +| `/api/v1/tracks/search` | GET | Recherche | +| `/api/v1/users/me` | GET | Profil utilisateur | +| `/api/v1/marketplace/orders` | POST | Création commande | +| `/api/v1/notifications` | GET | Notifications | +| `/api/v1/conversations` | GET | Conversations | +| `/api/v1/analytics/me` | GET | Analytics | +| `/health` | GET | Health check | + +## Cibles v1.0 (voir roadmap v0.951) + +- **P99 < 500ms** sur tous les endpoints critiques à 500 req/s +- **GET /tracks** : pagination cursor-based (v0.931) garantit des performances constantes quelle que soit la page + +## Commande pprof + +```bash +# Profiler 30s pendant un load test +go tool pprof -http=:8081 http://localhost:8080/debug/pprof/profile?seconds=30 +``` + +## Métriques Prometheus + +Les middlewares de monitoring exposent `http_request_duration_seconds` avec les labels `method`, `path`, `status`. Utiliser des histogram quantiles pour P50/P95/P99. diff --git a/go.work.sum b/go.work.sum index 0fec9b9d4..06471e1d6 100644 --- a/go.work.sum +++ b/go.work.sum @@ -68,6 +68,7 @@ github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJn github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -76,6 +77,7 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= @@ -121,6 +123,7 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= diff --git a/veza-backend-api/internal/core/social/service.go b/veza-backend-api/internal/core/social/service.go index dbdc6f1d6..1ec5f2f6d 100644 --- a/veza-backend-api/internal/core/social/service.go +++ b/veza-backend-api/internal/core/social/service.go @@ -2,8 +2,11 @@ package social import ( "context" + "encoding/base64" "fmt" + "strconv" "strings" + "time" "github.com/google/uuid" "go.uber.org/zap" @@ -17,6 +20,7 @@ import ( 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) @@ -97,7 +101,72 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType 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 @@ -108,7 +177,6 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType } } - // Batch fetch actor names/avatars actors := make(map[uuid.UUID]struct{ Name, Avatar string }) if len(actorIDs) > 0 { ids := make([]uuid.UUID, 0, len(actorIDs)) @@ -130,7 +198,6 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType } } - // Batch fetch tracks for target_type=track tracks := make(map[uuid.UUID]*models.Track) if len(trackIDs) > 0 { var tr []models.Track @@ -187,7 +254,6 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType feed = append(feed, item) } - return feed, nil } diff --git a/veza-backend-api/internal/core/track/service.go b/veza-backend-api/internal/core/track/service.go index b8ef3b338..d04d2eebc 100644 --- a/veza-backend-api/internal/core/track/service.go +++ b/veza-backend-api/internal/core/track/service.go @@ -2,14 +2,16 @@ package track import ( "context" + "encoding/base64" "errors" "fmt" "io" "mime/multipart" "os" "path/filepath" - "strings" // Removed strconv - "time" // MOD-P2-008: Ajouté pour timeout asynchrone + "strconv" + "strings" + "time" // MOD-P2-008: Ajouté pour timeout asynchrone "veza-backend-api/internal/database" "veza-backend-api/internal/models" @@ -554,6 +556,7 @@ func (s *TrackService) GetUserQuota(ctx context.Context, userID uuid.UUID) (*Use type TrackListParams struct { Page int Limit int + Cursor string // v0.931: opaque cursor for keyset pagination (base64) UserID *uuid.UUID Genre *string Format *string @@ -561,6 +564,13 @@ type TrackListParams struct { SortOrder string // "asc", "desc" } +// TrackListResult holds list result with optional next cursor +type TrackListResult struct { + Tracks []*models.Track + Total int64 + NextCursor string +} + // ListTracks récupère une liste de tracks avec pagination, filtres et tri func (s *TrackService) ListTracks(ctx context.Context, params TrackListParams) ([]*models.Track, int64, error) { // Créer la requête de base avec filtre sur le statut (read replica si configuré) @@ -633,6 +643,103 @@ func (s *TrackService) ListTracks(ctx context.Context, params TrackListParams) ( return tracks, total, nil } +// ListTracksWithCursor uses keyset pagination on (created_at, id) for consistent performance. +// When params.Cursor is set, decodes it and fetches records after that point. +// Returns NextCursor for the next page when more results exist. +// v0.931: Cursor-based pagination for GET /tracks +func (s *TrackService) ListTracksWithCursor(ctx context.Context, params TrackListParams) (*TrackListResult, error) { + // Cursor-based only supported for sort_by=created_at (default) + if params.SortBy != "created_at" && params.SortBy != "" { + // Fallback to offset-based + tracks, total, err := s.ListTracks(ctx, params) + if err != nil { + return nil, err + } + return &TrackListResult{Tracks: tracks, Total: total}, nil + } + + if params.Limit <= 0 { + params.Limit = 20 + } + if params.Limit > 100 { + params.Limit = 100 + } + + query := s.forRead().WithContext(ctx).Model(&models.Track{}).Where("status = ?", models.TrackStatusCompleted) + if params.UserID != nil { + query = query.Where("creator_id = ?", *params.UserID) + } + if params.Genre != nil && *params.Genre != "" { + query = query.Where("genre = ?", *params.Genre) + } + if params.Format != nil && *params.Format != "" { + query = query.Where("format = ?", *params.Format) + } + + // Decode cursor: base64(created_at_unix_nano|uuid) + var cursorCreatedAt int64 + var cursorID uuid.UUID + if params.Cursor != "" { + decoded, err := base64.RawURLEncoding.DecodeString(params.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 + } + } + } + } + + sortOrder := "DESC" + if params.SortOrder == "asc" { + sortOrder = "ASC" + } + if sortOrder == "DESC" { + if params.Cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) { + query = query.Where("(created_at, id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID) + } + query = query.Order("created_at DESC, id DESC") + } else { + if params.Cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) { + query = query.Where("(created_at, id) > (?, ?)", time.Unix(0, cursorCreatedAt), cursorID) + } + query = query.Order("created_at ASC, id ASC") + } + + // Fetch limit+1 to know if there's a next page + query = query.Limit(params.Limit + 1) + + var tracks []*models.Track + if err := query.Preload("User").Find(&tracks).Error; err != nil { + return nil, fmt.Errorf("failed to list tracks: %w", err) + } + + var nextCursor string + var total int64 + if len(tracks) > params.Limit { + // Has more - last fetched is the cursor for next page + last := tracks[params.Limit-1] + nextCursor = base64.RawURLEncoding.EncodeToString([]byte( + fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String()))) + tracks = tracks[:params.Limit] + } + // Total not computed for cursor mode (expensive); use -1 or len as approximation + total = int64(len(tracks)) + if nextCursor != "" { + total = int64(params.Limit) + 1 + } + + return &TrackListResult{ + Tracks: tracks, + Total: total, + NextCursor: nextCursor, + }, nil +} + // GetTrackByID récupère un track par son ID // MOD-P1-003: Preload User pour éviter N+1 queries si User est accédé plus tard // BE-SVC-001: Add caching for track metadata diff --git a/veza-backend-api/internal/core/track/track_crud_handler.go b/veza-backend-api/internal/core/track/track_crud_handler.go index 496d652d9..c27589ff3 100644 --- a/veza-backend-api/internal/core/track/track_crud_handler.go +++ b/veza-backend-api/internal/core/track/track_crud_handler.go @@ -47,8 +47,11 @@ type BatchUpdateRequest struct { Updates map[string]interface{} `json:"updates" binding:"required" validate:"required,min=1"` } -// ListTracks gère la liste des tracks avec pagination, filtres et tri +// ListTracks gère la liste des tracks avec pagination, filtres et tri. +// v0.931: Supports cursor-based pagination via ?cursor=xxx&limit=20 for consistent performance. +// Falls back to page/limit (offset) when cursor is not provided. func (h *TrackHandler) ListTracks(c *gin.Context) { + cursor := c.Query("cursor") page := c.DefaultQuery("page", "1") limit := c.DefaultQuery("limit", "20") userIDStr := c.Query("user_id") @@ -57,19 +60,15 @@ func (h *TrackHandler) ListTracks(c *gin.Context) { sortBy := c.DefaultQuery("sort_by", "created_at") sortOrder := c.DefaultQuery("sort_order", "desc") - var pageInt, limitInt int - if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 { - response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100") - return - } + var limitInt int if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 || limitInt > 100 { - response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100") + response.BadRequest(c, "pagination: limit must be between 1 and 100") return } params := TrackListParams{ - Page: pageInt, Limit: limitInt, + Cursor: cursor, SortBy: sortBy, SortOrder: sortOrder, } @@ -85,6 +84,36 @@ func (h *TrackHandler) ListTracks(c *gin.Context) { params.Format = &format } + _, exists := c.Get("user_id") + + if cursor != "" { + // Cursor-based pagination + result, err := h.trackService.ListTracksWithCursor(c.Request.Context(), params) + if err != nil { + response.InternalServerError(c, "failed to list tracks") + return + } + if !exists { + for _, t := range result.Tracks { + t.StreamManifestURL = "" + } + } + pagination := handlers.BuildPaginationDataWithCursor(limitInt, result.Total, result.NextCursor, "") + response.Success(c, gin.H{ + "tracks": result.Tracks, + "pagination": pagination, + }) + return + } + + // Offset-based pagination (fallback) + var pageInt int + if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 { + response.BadRequest(c, "pagination: page must be >= 1 when not using cursor") + return + } + params.Page = pageInt + tracks, total, err := h.trackService.ListTracks(c.Request.Context(), params) if err != nil { response.InternalServerError(c, "failed to list tracks") @@ -92,7 +121,6 @@ func (h *TrackHandler) ListTracks(c *gin.Context) { } pagination := handlers.BuildPaginationData(pageInt, limitInt, total) - _, exists := c.Get("user_id") if !exists { for _, t := range tracks { t.StreamManifestURL = "" diff --git a/veza-backend-api/internal/handlers/room_handler.go b/veza-backend-api/internal/handlers/room_handler.go index 822dae36d..b43e9d155 100644 --- a/veza-backend-api/internal/handlers/room_handler.go +++ b/veza-backend-api/internal/handlers/room_handler.go @@ -23,6 +23,7 @@ type RoomServiceInterface interface { AddMember(ctx context.Context, roomID, userID uuid.UUID) error RemoveMember(ctx context.Context, roomID, userID uuid.UUID) error // BE-API-011: Remove member method GetRoomHistory(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error) + GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*services.RoomHistoryWithCursorResult, error) // v0.931: cursor pagination DeleteRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error // BE-API-010: Delete room method } @@ -245,6 +246,7 @@ func (h *RoomHandler) AddMember(c *gin.Context) { // GetRoomHistory récupère l'historique des messages d'une room // GET /api/v1/conversations/:id/history +// v0.931: Supports cursor-based pagination via ?cursor=xxx&limit=20. Falls back to offset when cursor not provided. func (h *RoomHandler) GetRoomHistory(c *gin.Context) { conversationIDStr := c.Param("id") conversationID, err := uuid.Parse(conversationIDStr) @@ -254,8 +256,6 @@ func (h *RoomHandler) GetRoomHistory(c *gin.Context) { } limit := c.DefaultQuery("limit", "50") - offset := c.DefaultQuery("offset", "0") - limitInt, err := strconv.Atoi(limit) if err != nil || limitInt <= 0 { limitInt = 50 @@ -263,6 +263,30 @@ func (h *RoomHandler) GetRoomHistory(c *gin.Context) { if limitInt > 100 { limitInt = 100 } + cursor := c.Query("cursor") + + if cursor != "" { + result, err := h.roomService.GetRoomHistoryWithCursor(c.Request.Context(), conversationID, limitInt, cursor) + if err != nil { + if errors.Is(err, services.ErrRoomNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) + return + } + h.logger.Error("failed to get room history", + zap.Error(err), + zap.String("conversation_id", conversationID.String())) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get conversation history"}) + return + } + resp := gin.H{"messages": result.Messages} + if result.NextCursor != "" { + resp["next_cursor"] = result.NextCursor + } + RespondSuccess(c, http.StatusOK, resp) + return + } + + offset := c.DefaultQuery("offset", "0") offsetInt, err := strconv.Atoi(offset) if err != nil || offsetInt < 0 { offsetInt = 0 diff --git a/veza-backend-api/internal/handlers/room_handler_test.go b/veza-backend-api/internal/handlers/room_handler_test.go index 52cebd0af..17e961afd 100644 --- a/veza-backend-api/internal/handlers/room_handler_test.go +++ b/veza-backend-api/internal/handlers/room_handler_test.go @@ -17,14 +17,15 @@ import ( // MockRoomService implements RoomServiceInterface for testing type MockRoomService struct { - CreateRoomFunc func(ctx context.Context, userID uuid.UUID, req services.CreateRoomRequest) (*services.RoomResponse, error) - GetUserRoomsFunc func(ctx context.Context, userID uuid.UUID) ([]*services.RoomResponse, error) - GetRoomFunc func(ctx context.Context, roomID uuid.UUID) (*services.RoomResponse, error) - UpdateRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error) - AddMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error - RemoveMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error - GetRoomHistoryFunc func(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error) - DeleteRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error + CreateRoomFunc func(ctx context.Context, userID uuid.UUID, req services.CreateRoomRequest) (*services.RoomResponse, error) + GetUserRoomsFunc func(ctx context.Context, userID uuid.UUID) ([]*services.RoomResponse, error) + GetRoomFunc func(ctx context.Context, roomID uuid.UUID) (*services.RoomResponse, error) + UpdateRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error) + AddMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error + RemoveMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error + GetRoomHistoryFunc func(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error) + GetRoomHistoryWithCursorFunc func(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*services.RoomHistoryWithCursorResult, error) + DeleteRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error } func (m *MockRoomService) CreateRoom(ctx context.Context, userID uuid.UUID, req services.CreateRoomRequest) (*services.RoomResponse, error) { @@ -62,6 +63,13 @@ func (m *MockRoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID, return nil, nil } +func (m *MockRoomService) GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*services.RoomHistoryWithCursorResult, error) { + if m.GetRoomHistoryWithCursorFunc != nil { + return m.GetRoomHistoryWithCursorFunc(ctx, roomID, limit, cursor) + } + return &services.RoomHistoryWithCursorResult{Messages: nil, NextCursor: ""}, nil +} + func (m *MockRoomService) UpdateRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error) { if m.UpdateRoomFunc != nil { return m.UpdateRoomFunc(ctx, roomID, userID, req) diff --git a/veza-backend-api/internal/handlers/social.go b/veza-backend-api/internal/handlers/social.go index 1bd920ba9..6452bac23 100644 --- a/veza-backend-api/internal/handlers/social.go +++ b/veza-backend-api/internal/handlers/social.go @@ -156,6 +156,7 @@ func (h *SocialHandler) AddComment(c *gin.Context) { } // GetFeed récupère le feed global (S1.2, S1.4, S1.6: cursor, limit, type filter) +// v0.931: Cursor-based pagination via ?cursor=base64. Falls back to offset when cursor not provided. func (h *SocialHandler) GetFeed(c *gin.Context) { feedType := c.DefaultQuery("type", "all") // all | following | groups if feedType != "all" && feedType != "following" && feedType != "groups" { @@ -170,15 +171,19 @@ func (h *SocialHandler) GetFeed(c *gin.Context) { limit = 50 } } - // cursor: timestamp or ID for pagination (S1.4) - for now we use offset via cursor cursor := c.Query("cursor") offset := 0 + if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 { + offset = o + } if cursor != "" { - // Simple cursor: if it's a number, use as offset; else ignore if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { - offset = o + offset = o // Backward compat: cursor as number = offset } } + // Use keyset pagination when cursor looks like base64 (Atoi fails) + _, numErr := strconv.Atoi(cursor) + useCursorKeyset := cursor != "" && numErr != nil var userID *uuid.UUID if feedType == "following" { @@ -188,6 +193,20 @@ func (h *SocialHandler) GetFeed(c *gin.Context) { feedType = "all" // Fallback if not authenticated } } + + if useCursorKeyset { + feed, nextCursor, err := h.service.GetGlobalFeedWithCursor(c.Request.Context(), limit, cursor, feedType, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get feed"}) + return + } + RespondSuccess(c, http.StatusOK, gin.H{ + "items": feed, + "next_cursor": nextCursor, + }) + return + } + feed, err := h.service.GetGlobalFeed(c.Request.Context(), limit, offset, feedType, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get feed"}) diff --git a/veza-backend-api/internal/handlers/social_test.go b/veza-backend-api/internal/handlers/social_test.go index c212ed73c..ad091a162 100644 --- a/veza-backend-api/internal/handlers/social_test.go +++ b/veza-backend-api/internal/handlers/social_test.go @@ -48,6 +48,14 @@ func (m *MockSocialService) GetGlobalFeed(ctx context.Context, limit, offset int return args.Get(0).([]social.FeedItem), args.Error(1) } +func (m *MockSocialService) GetGlobalFeedWithCursor(ctx context.Context, limit int, cursor, feedType string, userID *uuid.UUID) ([]social.FeedItem, string, error) { + args := m.Called(ctx, limit, cursor, feedType, userID) + if args.Get(0) == nil { + return nil, "", args.Error(2) + } + return args.Get(0).([]social.FeedItem), args.String(1), args.Error(2) +} + func (m *MockSocialService) GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]social.FeedItem, error) { args := m.Called(ctx, userID, limit, offset) if args.Get(0) == nil { diff --git a/veza-backend-api/internal/repositories/chat_message_repository.go b/veza-backend-api/internal/repositories/chat_message_repository.go index e9098a95b..4f17a705e 100644 --- a/veza-backend-api/internal/repositories/chat_message_repository.go +++ b/veza-backend-api/internal/repositories/chat_message_repository.go @@ -2,7 +2,10 @@ package repositories import ( "context" + "encoding/base64" "fmt" + "strconv" + "strings" "time" "veza-backend-api/internal/models" @@ -63,6 +66,61 @@ func (r *ChatMessageRepository) GetConversationMessages(ctx context.Context, con return messages, nil } +// ConversationMessagesWithCursorResult holds messages and next cursor for cursor-based pagination (v0.931). +type ConversationMessagesWithCursorResult struct { + Messages []models.ChatMessage + NextCursor string +} + +// GetConversationMessagesWithCursor uses keyset pagination on (created_at, id) for consistent performance. +// Cursor format: base64(created_at_unix_nano|uuid). Order DESC (newest first), so cursor yields older messages. +func (r *ChatMessageRepository) GetConversationMessagesWithCursor(ctx context.Context, conversationID uuid.UUID, limit int, cursor string) (*ConversationMessagesWithCursorResult, error) { + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + query := r.db.WithContext(ctx). + Where("room_id = ? AND is_deleted = ?", conversationID, false) + + 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("(created_at, id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID) + } + query = query.Order("created_at DESC, id DESC").Limit(limit + 1) + + var messages []models.ChatMessage + if err := query.Find(&messages).Error; err != nil { + return nil, fmt.Errorf("failed to get conversation messages: %w", err) + } + + var nextCursor string + if len(messages) > limit { + last := messages[limit-1] + nextCursor = base64.RawURLEncoding.EncodeToString([]byte( + fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String()))) + messages = messages[:limit] + } + return &ConversationMessagesWithCursorResult{Messages: messages, NextCursor: nextCursor}, nil +} + func (r *ChatMessageRepository) GetMessagesBefore(ctx context.Context, roomID uuid.UUID, beforeID uuid.UUID, limit int) ([]models.ChatMessage, error) { var refMsg models.ChatMessage if err := r.db.WithContext(ctx).Where("id = ?", beforeID).First(&refMsg).Error; err != nil { diff --git a/veza-backend-api/internal/services/room_service.go b/veza-backend-api/internal/services/room_service.go index edb6dba6a..95ff20a34 100644 --- a/veza-backend-api/internal/services/room_service.go +++ b/veza-backend-api/internal/services/room_service.go @@ -357,6 +357,38 @@ func (s *RoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID, limi return responses, nil } +// RoomHistoryWithCursorResult holds messages and next cursor for cursor-based pagination (v0.931). +type RoomHistoryWithCursorResult struct { + Messages []ChatMessageResponse + NextCursor string +} + +// GetRoomHistoryWithCursor uses keyset pagination on (created_at, id) for consistent performance. +func (s *RoomService) GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*RoomHistoryWithCursorResult, error) { + result, err := s.messageRepo.GetConversationMessagesWithCursor(ctx, roomID, limit, cursor) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == "conversation not found" { + return nil, ErrRoomNotFound + } + s.logger.Error("failed to get room history with cursor", + zap.Error(err), + zap.String("room_id", roomID.String())) + return nil, fmt.Errorf("failed to get room history: %w", err) + } + responses := make([]ChatMessageResponse, len(result.Messages)) + for i, msg := range result.Messages { + responses[i] = ChatMessageResponse{ + ID: msg.ID, + ConversationID: msg.ConversationID, + SenderID: msg.SenderID, + Content: msg.Content, + MessageType: msg.MessageType, + CreatedAt: msg.CreatedAt, + } + } + return &RoomHistoryWithCursorResult{Messages: responses, NextCursor: result.NextCursor}, nil +} + // DeleteRoom supprime une room (soft delete) // BE-API-010: Implement conversation delete endpoint // Seul le créateur de la room ou un admin peut supprimer la room