404 lines
11 KiB
Go
404 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
"veza-backend-api/internal/models"
|
|
)
|
|
|
|
// 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
|
|
}
|