package handlers import ( "net/http" "sync" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/patbritton/sojorn-backend/internal/models" "github.com/patbritton/sojorn-backend/internal/repository" "github.com/patbritton/sojorn-backend/internal/services" "github.com/patbritton/sojorn-backend/pkg/utils" "github.com/rs/zerolog/log" ) type DiscoverHandler struct { userRepo *repository.UserRepository postRepo *repository.PostRepository tagRepo *repository.TagRepository categoryRepo repository.CategoryRepository assetService *services.AssetService } func NewDiscoverHandler( userRepo *repository.UserRepository, postRepo *repository.PostRepository, tagRepo *repository.TagRepository, categoryRepo repository.CategoryRepository, assetService *services.AssetService, ) *DiscoverHandler { return &DiscoverHandler{ userRepo: userRepo, postRepo: postRepo, tagRepo: tagRepo, categoryRepo: categoryRepo, assetService: assetService, } } // GetDiscover returns the discover page data // GET /api/v1/discover func (h *DiscoverHandler) GetDiscover(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID := userIDStr.(string) ctx := c.Request.Context() start := time.Now() var wg sync.WaitGroup var trending []models.Hashtag var popularPosts []models.Post wg.Add(2) // Get top 4 tags go func() { defer wg.Done() var err error trending, err = h.tagRepo.GetTrendingHashtags(ctx, 4) if err != nil { log.Warn().Err(err).Msg("Failed to get trending hashtags") } }() // Get popular public posts go func() { defer wg.Done() var err error popularPosts, err = h.postRepo.GetPopularPublicPosts(ctx, userID, 20) if err != nil { log.Warn().Err(err).Msg("Failed to get popular posts") } }() wg.Wait() // Sign URLs for i := range popularPosts { h.signPostURLs(&popularPosts[i]) } // Ensure non-nil slices if trending == nil { trending = []models.Hashtag{} } if popularPosts == nil { popularPosts = []models.Post{} } response := gin.H{ "top_tags": trending, "popular_posts": popularPosts, } log.Debug().Dur("duration", time.Since(start)).Msg("Discover page generated") c.JSON(http.StatusOK, response) } // Search performs a combined search across users, hashtags, and posts // GET /api/v1/search func (h *DiscoverHandler) Search(c *gin.Context) { query := c.Query("q") searchType := c.Query("type") // "all", "users", "hashtags", "posts" if query == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"}) return } if searchType == "" { searchType = "all" } ctx := c.Request.Context() start := time.Now() viewerID := "" if val, exists := c.Get("user_id"); exists { viewerID = val.(string) } var wg sync.WaitGroup var users []models.Profile var hashtags []models.Hashtag var posts []models.Post var tags []models.TagResult if searchType == "all" || searchType == "users" { wg.Add(1) go func() { defer wg.Done() var err error users, err = h.userRepo.SearchUsers(ctx, query, viewerID, 10) if err != nil { log.Warn().Err(err).Msg("Failed to search users") users = []models.Profile{} } }() } if searchType == "all" || searchType == "hashtags" { wg.Add(1) go func() { defer wg.Done() var err error hashtags, err = h.tagRepo.SearchHashtags(ctx, query, 10) if err != nil { log.Warn().Err(err).Msg("Failed to search hashtags") hashtags = []models.Hashtag{} } // Also get legacy tag results for backward compatibility tags, _ = h.postRepo.SearchTags(ctx, query, 5) }() } if searchType == "all" || searchType == "posts" { wg.Add(1) go func() { defer wg.Done() var err error posts, err = h.postRepo.SearchPosts(ctx, query, viewerID, 20) if err != nil { log.Warn().Err(err).Msg("Failed to search posts") posts = []models.Post{} } }() } wg.Wait() // Sign URLs for i := range users { if users[i].AvatarURL != nil { signed := h.assetService.SignImageURL(*users[i].AvatarURL) users[i].AvatarURL = &signed } } for i := range posts { h.signPostURLs(&posts[i]) } // Ensure non-nil slices if users == nil { users = []models.Profile{} } if hashtags == nil { hashtags = []models.Hashtag{} } if tags == nil { tags = []models.TagResult{} } if posts == nil { posts = []models.Post{} } response := gin.H{ "users": users, "hashtags": hashtags, "tags": tags, // Legacy format "posts": posts, "query": query, } log.Info().Str("query", query).Str("type", searchType).Dur("duration", time.Since(start)).Msg("Search completed") c.JSON(http.StatusOK, response) } // GetHashtagPage returns posts for a specific hashtag // GET /api/v1/hashtags/:name func (h *DiscoverHandler) GetHashtagPage(c *gin.Context) { hashtagName := c.Param("name") if hashtagName == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Hashtag name required"}) return } ctx := c.Request.Context() limit := utils.GetQueryInt(c, "limit", 20) offset := utils.GetQueryInt(c, "offset", 0) viewerID := "" if val, exists := c.Get("user_id"); exists { viewerID = val.(string) } // Get hashtag info hashtag, err := h.tagRepo.GetHashtagByName(ctx, hashtagName) if err != nil || hashtag == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Hashtag not found"}) return } // Check if user follows this hashtag isFollowing := false if viewerID != "" { userUUID, err := uuid.Parse(viewerID) if err == nil { isFollowing, _ = h.tagRepo.IsFollowingHashtag(ctx, userUUID, hashtag.ID) } } // Get posts posts, err := h.tagRepo.GetPostsByHashtag(ctx, hashtagName, viewerID, limit, offset) if err != nil { log.Error().Err(err).Str("hashtag", hashtagName).Msg("Failed to get posts by hashtag") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch posts"}) return } // Sign URLs for i := range posts { h.signPostURLs(&posts[i]) } if posts == nil { posts = []models.Post{} } response := models.HashtagPageResponse{ Hashtag: *hashtag, Posts: posts, IsFollowing: isFollowing, TotalPosts: hashtag.UseCount, } c.JSON(http.StatusOK, response) } // FollowHashtag follows a hashtag // POST /api/v1/hashtags/:name/follow func (h *DiscoverHandler) FollowHashtag(c *gin.Context) { hashtagName := c.Param("name") userIDStr, _ := c.Get("user_id") userUUID, _ := uuid.Parse(userIDStr.(string)) ctx := c.Request.Context() // Get or create the hashtag hashtag, err := h.tagRepo.GetOrCreateHashtag(ctx, hashtagName) if err != nil || hashtag == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process hashtag"}) return } if err := h.tagRepo.FollowHashtag(ctx, userUUID, hashtag.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow hashtag"}) return } c.JSON(http.StatusOK, gin.H{"message": "Now following #" + hashtag.Name}) } // UnfollowHashtag unfollows a hashtag // DELETE /api/v1/hashtags/:name/follow func (h *DiscoverHandler) UnfollowHashtag(c *gin.Context) { hashtagName := c.Param("name") userIDStr, _ := c.Get("user_id") userUUID, _ := uuid.Parse(userIDStr.(string)) ctx := c.Request.Context() hashtag, err := h.tagRepo.GetHashtagByName(ctx, hashtagName) if err != nil || hashtag == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Hashtag not found"}) return } if err := h.tagRepo.UnfollowHashtag(ctx, userUUID, hashtag.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow hashtag"}) return } c.JSON(http.StatusOK, gin.H{"message": "Unfollowed #" + hashtag.Name}) } // GetTrendingHashtags returns trending hashtags // GET /api/v1/hashtags/trending func (h *DiscoverHandler) GetTrendingHashtags(c *gin.Context) { limit := utils.GetQueryInt(c, "limit", 20) hashtags, err := h.tagRepo.GetTrendingHashtags(c.Request.Context(), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch trending hashtags"}) return } if hashtags == nil { hashtags = []models.Hashtag{} } c.JSON(http.StatusOK, gin.H{"hashtags": hashtags}) } // GetFollowedHashtags returns hashtags the user follows // GET /api/v1/hashtags/following func (h *DiscoverHandler) GetFollowedHashtags(c *gin.Context) { userIDStr, _ := c.Get("user_id") userUUID, _ := uuid.Parse(userIDStr.(string)) hashtags, err := h.tagRepo.GetFollowedHashtags(c.Request.Context(), userUUID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch followed hashtags"}) return } if hashtags == nil { hashtags = []models.Hashtag{} } c.JSON(http.StatusOK, gin.H{"hashtags": hashtags}) } // signPostURLs signs all URLs in a post func (h *DiscoverHandler) signPostURLs(post *models.Post) { if post.ImageURL != nil { signed := h.assetService.SignImageURL(*post.ImageURL) post.ImageURL = &signed } if post.VideoURL != nil { signed := h.assetService.SignVideoURL(*post.VideoURL) post.VideoURL = &signed } if post.Author != nil && post.Author.AvatarURL != "" { post.Author.AvatarURL = h.assetService.SignImageURL(post.Author.AvatarURL) } }