package services import ( "context" "fmt" "strings" "veza-backend-api/internal/models" "go.uber.org/zap" "gorm.io/gorm" ) // FullTextSearchService provides full-text search using PostgreSQL tsvector/tsquery // BE-SVC-006: Implement search service using PostgreSQL full-text search type FullTextSearchService struct { db *gorm.DB logger *zap.Logger } // NewFullTextSearchService creates a new full-text search service func NewFullTextSearchService(db *gorm.DB, logger *zap.Logger) *FullTextSearchService { if logger == nil { logger = zap.NewNop() } return &FullTextSearchService{ db: db, logger: logger, } } // SearchParams represents search parameters type SearchParams struct { Query string // Search query text Types []string // Types to search: "track", "user", "playlist" Page int // Page number (default: 1) Limit int // Results per page (default: 20, max: 100) MinScore float64 // Minimum relevance score (0.0-1.0) } // FullTextSearchResult represents unified full-text search results type FullTextSearchResult struct { Tracks []TrackSearchResult `json:"tracks"` Users []UserSearchResult `json:"users"` Playlists []PlaylistSearchResult `json:"playlists"` Total int64 `json:"total"` Page int `json:"page"` Limit int `json:"limit"` } // TrackSearchResult represents a track search result with relevance score type TrackSearchResult struct { models.Track Relevance float64 `json:"relevance"` } // UserSearchResult represents a user search result with relevance score type UserSearchResult struct { models.User Relevance float64 `json:"relevance"` } // PlaylistSearchResult represents a playlist search result with relevance score type PlaylistSearchResult struct { models.Playlist Relevance float64 `json:"relevance"` } // Search performs a full-text search across tracks, users, and playlists func (s *FullTextSearchService) Search(ctx context.Context, params SearchParams) (*FullTextSearchResult, error) { result := &FullTextSearchResult{ Tracks: []TrackSearchResult{}, Users: []UserSearchResult{}, Playlists: []PlaylistSearchResult{}, Page: params.Page, Limit: params.Limit, } // Validate and set defaults if params.Page < 1 { params.Page = 1 } if params.Limit < 1 { params.Limit = 20 } if params.Limit > 100 { params.Limit = 100 } if params.MinScore < 0 { params.MinScore = 0 } if params.MinScore > 1 { params.MinScore = 1 } // Build search types - if empty, search all searchAll := len(params.Types) == 0 searchTracks := searchAll || containsString(params.Types, "track") searchUsers := searchAll || containsString(params.Types, "user") searchPlaylists := searchAll || containsString(params.Types, "playlist") // Prepare search query for PostgreSQL tsquery searchQuery := s.prepareSearchQuery(params.Query) // Search tracks if searchTracks && params.Query != "" { tracks, err := s.searchTracks(ctx, searchQuery, params) if err != nil { s.logger.Warn("Failed to search tracks", zap.Error(err)) } else { result.Tracks = tracks } } // Search users if searchUsers && params.Query != "" { users, err := s.searchUsers(ctx, searchQuery, params) if err != nil { s.logger.Warn("Failed to search users", zap.Error(err)) } else { result.Users = users } } // Search playlists if searchPlaylists && params.Query != "" { playlists, err := s.searchPlaylists(ctx, searchQuery, params) if err != nil { s.logger.Warn("Failed to search playlists", zap.Error(err)) } else { result.Playlists = playlists } } // Calculate total result.Total = int64(len(result.Tracks) + len(result.Users) + len(result.Playlists)) return result, nil } // prepareSearchQuery converts a search query to PostgreSQL tsquery format func (s *FullTextSearchService) prepareSearchQuery(query string) string { if query == "" { return "" } // Remove special characters and split into words words := strings.Fields(strings.ToLower(query)) // Build tsquery: word1 & word2 & word3 (AND search) // Use prefix matching with :* for better results tsquery := strings.Join(words, " & ") // Add prefix matching to last word if it's not too short if len(words) > 0 { lastWord := words[len(words)-1] if len(lastWord) > 2 { tsquery = strings.TrimSuffix(tsquery, lastWord) + lastWord + ":*" } } return tsquery } // searchTracks performs full-text search on tracks using PostgreSQL tsvector func (s *FullTextSearchService) searchTracks(ctx context.Context, searchQuery string, params SearchParams) ([]TrackSearchResult, error) { offset := (params.Page - 1) * params.Limit // Build tsvector from title and artist // Use the GIN indexes created in migration 048_search_indexes.sql var results []struct { models.Track Relevance float64 } query := s.db.WithContext(ctx). Model(&models.Track{}). Select(` tracks.*, ts_rank_cd( setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(artist, '')), 'B'), plainto_tsquery('english', ?) ) as relevance `, searchQuery). Where("is_public = ? AND deleted_at IS NULL", true). Where(` to_tsvector('english', COALESCE(title, '')) || to_tsvector('english', COALESCE(artist, '')) @@ plainto_tsquery('english', ?) `, searchQuery). Having("relevance >= ?", params.MinScore). Order("relevance DESC, created_at DESC"). Offset(offset). Limit(params.Limit) if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to search tracks: %w", err) } trackResults := make([]TrackSearchResult, len(results)) for i, r := range results { trackResults[i] = TrackSearchResult{ Track: r.Track, Relevance: r.Relevance, } } return trackResults, nil } // searchUsers performs full-text search on users using PostgreSQL tsvector func (s *FullTextSearchService) searchUsers(ctx context.Context, searchQuery string, params SearchParams) ([]UserSearchResult, error) { offset := (params.Page - 1) * params.Limit var results []struct { models.User Relevance float64 } query := s.db.WithContext(ctx). Model(&models.User{}). Select(` users.*, ts_rank_cd( setweight(to_tsvector('english', COALESCE(username, '')), 'A') || setweight(to_tsvector('english', COALESCE(first_name, '')), 'B') || setweight(to_tsvector('english', COALESCE(last_name, '')), 'B'), plainto_tsquery('english', ?) ) as relevance `, searchQuery). Where("deleted_at IS NULL"). Where(` to_tsvector('english', COALESCE(username, '')) || to_tsvector('english', COALESCE(first_name, '')) || to_tsvector('english', COALESCE(last_name, '')) @@ plainto_tsquery('english', ?) `, searchQuery). Having("relevance >= ?", params.MinScore). Order("relevance DESC, created_at DESC"). Offset(offset). Limit(params.Limit) if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to search users: %w", err) } userResults := make([]UserSearchResult, len(results)) for i, r := range results { // Exclude password hash r.User.PasswordHash = "" userResults[i] = UserSearchResult{ User: r.User, Relevance: r.Relevance, } } return userResults, nil } // searchPlaylists performs full-text search on playlists using PostgreSQL tsvector func (s *FullTextSearchService) searchPlaylists(ctx context.Context, searchQuery string, params SearchParams) ([]PlaylistSearchResult, error) { offset := (params.Page - 1) * params.Limit var results []struct { models.Playlist Relevance float64 } query := s.db.WithContext(ctx). Model(&models.Playlist{}). Select(` playlists.*, ts_rank_cd( setweight(to_tsvector('english', COALESCE(name, '')), 'A') || setweight(to_tsvector('english', COALESCE(description, '')), 'B'), plainto_tsquery('english', ?) ) as relevance `, searchQuery). Where("is_public = ? AND deleted_at IS NULL", true). Where(` to_tsvector('english', COALESCE(name, '')) || to_tsvector('english', COALESCE(description, '')) @@ plainto_tsquery('english', ?) `, searchQuery). Having("relevance >= ?", params.MinScore). Order("relevance DESC, created_at DESC"). Offset(offset). Limit(params.Limit) if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to search playlists: %w", err) } playlistResults := make([]PlaylistSearchResult, len(results)) for i, r := range results { playlistResults[i] = PlaylistSearchResult{ Playlist: r.Playlist, Relevance: r.Relevance, } } return playlistResults, nil } // SearchTracksOnly performs full-text search on tracks only func (s *FullTextSearchService) SearchTracksOnly(ctx context.Context, query string, page, limit int) ([]TrackSearchResult, int64, error) { params := SearchParams{ Query: query, Page: page, Limit: limit, Types: []string{"track"}, } tracks, err := s.searchTracks(ctx, s.prepareSearchQuery(query), params) if err != nil { return nil, 0, err } // Get total count var total int64 countQuery := s.db.WithContext(ctx). Model(&models.Track{}). Where("is_public = ? AND deleted_at IS NULL", true). Where(` to_tsvector('english', COALESCE(title, '')) || to_tsvector('english', COALESCE(artist, '')) @@ plainto_tsquery('english', ?) `, s.prepareSearchQuery(query)) if err := countQuery.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count tracks: %w", err) } return tracks, total, nil } // SearchUsersOnly performs full-text search on users only func (s *FullTextSearchService) SearchUsersOnly(ctx context.Context, query string, page, limit int) ([]UserSearchResult, int64, error) { params := SearchParams{ Query: query, Page: page, Limit: limit, Types: []string{"user"}, } users, err := s.searchUsers(ctx, s.prepareSearchQuery(query), params) if err != nil { return nil, 0, err } // Get total count var total int64 countQuery := s.db.WithContext(ctx). Model(&models.User{}). Where("deleted_at IS NULL"). Where(` to_tsvector('english', COALESCE(username, '')) || to_tsvector('english', COALESCE(first_name, '')) || to_tsvector('english', COALESCE(last_name, '')) @@ plainto_tsquery('english', ?) `, s.prepareSearchQuery(query)) if err := countQuery.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count users: %w", err) } return users, total, nil } // SearchPlaylistsOnly performs full-text search on playlists only func (s *FullTextSearchService) SearchPlaylistsOnly(ctx context.Context, query string, page, limit int) ([]PlaylistSearchResult, int64, error) { params := SearchParams{ Query: query, Page: page, Limit: limit, Types: []string{"playlist"}, } playlists, err := s.searchPlaylists(ctx, s.prepareSearchQuery(query), params) if err != nil { return nil, 0, err } // Get total count var total int64 countQuery := s.db.WithContext(ctx). Model(&models.Playlist{}). Where("is_public = ? AND deleted_at IS NULL", true). Where(` to_tsvector('english', COALESCE(name, '')) || to_tsvector('english', COALESCE(description, '')) @@ plainto_tsquery('english', ?) `, s.prepareSearchQuery(query)) if err := countQuery.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count playlists: %w", err) } return playlists, total, nil } // containsString checks if a slice contains a string func containsString(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false }