package handlers import ( "context" "net/http" "strings" "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/rs/zerolog/log" ) type UserHandler struct { repo *repository.UserRepository postRepo *repository.PostRepository pushService *services.PushService assetService *services.AssetService } func NewUserHandler(repo *repository.UserRepository, postRepo *repository.PostRepository, pushService *services.PushService, assetService *services.AssetService) *UserHandler { return &UserHandler{ repo: repo, postRepo: postRepo, pushService: pushService, assetService: assetService, } } func (h *UserHandler) GetProfile(c *gin.Context) { userID := c.Param("id") handle, handleExists := c.GetQuery("handle") var profile *models.Profile var err error if userID != "" { profile, err = h.repo.GetProfileByID(c.Request.Context(), userID) } else if handleExists { if handle == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Handle cannot be empty"}) return } profile, err = h.repo.GetProfileByHandle(c.Request.Context(), handle) } else { // Fallback to current authenticated user if val, exists := c.Get("user_id"); exists { userID = val.(string) profile, err = h.repo.GetProfileByID(c.Request.Context(), userID) } } if err != nil || profile == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"}) return } // Use the profile ID for subsequent lookups actualUserID := profile.ID.String() // Get stats stats, _ := h.repo.GetProfileStats(c.Request.Context(), actualUserID) // Check follow status if authenticated isFollowing := false isFollowedBy := false followStatus := "" if currentUserID, exists := c.Get("user_id"); exists && currentUserID.(string) != actualUserID { var err error isFollowing, err = h.repo.IsFollowing(c.Request.Context(), currentUserID.(string), actualUserID) if err != nil { log.Error().Err(err).Msg("Failed to check isFollowing") } isFollowedBy, err = h.repo.IsFollowing(c.Request.Context(), actualUserID, currentUserID.(string)) if err != nil { log.Error().Err(err).Msg("Failed to check isFollowedBy") } followStatus, _ = h.repo.GetFollowStatus(c.Request.Context(), currentUserID.(string), actualUserID) } // Sign URLs if profile.AvatarURL != nil { signed := h.assetService.SignImageURL(*profile.AvatarURL) profile.AvatarURL = &signed } if profile.CoverURL != nil { signed := h.assetService.SignImageURL(*profile.CoverURL) profile.CoverURL = &signed } c.JSON(http.StatusOK, gin.H{ "profile": profile, "stats": stats, "is_following": isFollowing, "is_followed_by": isFollowedBy, "is_friend": isFollowing && isFollowedBy, "follow_status": followStatus, "is_private": profile.IsPrivate, }) } func (h *UserHandler) Follow(c *gin.Context) { followerID, exists := c.Get("user_id") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } followingID := c.Param("id") if followingID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"}) return } status, err := h.repo.FollowUser(c.Request.Context(), followerID.(string), followingID) if err != nil { if strings.Contains(err.Error(), "cannot follow self") { c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot follow self"}) return } if strings.Contains(err.Error(), "23503") || strings.Contains(err.Error(), "target profile not found") { // FK Violation or custom error c.JSON(http.StatusNotFound, gin.H{"error": "User to follow not found"}) return } log.Error().Err(err).Msg("Failed to follow user") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow user", "details": err.Error()}) return } // Send Push Notification go func(targetID string, actorID string, status string) { message := "You have a new follower!" msgType := "new_follower" if status == "pending" { message = "You have a new follow request!" msgType = "follow_request" } err := h.pushService.SendPush(context.Background(), targetID, "New Follower", message, map[string]string{ "type": msgType, "follower_id": actorID, }) if err != nil { log.Error().Err(err).Msg("Failed to send push notification") } }(followingID, followerID.(string), status) c.JSON(http.StatusOK, gin.H{"message": "Follow update successful", "status": status}) } func (h *UserHandler) Unfollow(c *gin.Context) { followerID, exists := c.Get("user_id") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } followingID := c.Param("id") if followingID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"}) return } if err := h.repo.UnfollowUser(c.Request.Context(), followerID.(string), followingID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow user"}) return } c.JSON(http.StatusOK, gin.H{"message": "Unfollowed successfully"}) } func (h *UserHandler) UpdateProfile(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) var req struct { Handle *string `json:"handle"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` AvatarURL *string `json:"avatar_url"` CoverURL *string `json:"cover_url"` Location *string `json:"location"` Website *string `json:"website"` Interests []string `json:"interests"` IdentityKey *string `json:"identity_key"` RegistrationID *int `json:"registration_id"` EncryptedPrivateKey *string `json:"encrypted_private_key"` IsPrivate *bool `json:"is_private"` IsOfficial *bool `json:"is_official"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } profile := &models.Profile{ ID: userID, Handle: req.Handle, DisplayName: req.DisplayName, Bio: req.Bio, AvatarURL: req.AvatarURL, CoverURL: req.CoverURL, Location: req.Location, Website: req.Website, Interests: req.Interests, IdentityKey: req.IdentityKey, RegistrationID: req.RegistrationID, EncryptedPrivateKey: req.EncryptedPrivateKey, IsPrivate: req.IsPrivate, IsOfficial: req.IsOfficial, } err := h.repo.UpdateProfile(c.Request.Context(), profile) if err != nil { // Log error log.Error().Err(err).Msg("Failed to update profile") // Check for duplicate handle if strings.Contains(err.Error(), "23505") { c.JSON(http.StatusConflict, gin.H{"error": "Handle already taken"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile", "details": err.Error()}) return } updated, _ := h.repo.GetProfileByID(c.Request.Context(), userID.String()) c.JSON(http.StatusOK, gin.H{"profile": updated}) } func (h *UserHandler) GetSavedPosts(c *gin.Context) { userID := c.GetString("user_id") // Authenticated user // Optional: allow viewing other's saved posts if public? For now assume "me" context from route usually, // but if route is /users/:id/saved we would use param. // The prompt asked for GET /users/me/saved, which implies authenticated user. // Check if a specific ID is requested in URL, otherwise use authenticated user // However, usually saved posts are private. Let's assume me-only for now or strictly follow ID if provided. // The prompt says GET /api/v1/users/me/saved // If the route is /users/me/saved, we rely on the middleware setting user_id. limit := 20 offset := 0 // simplified for brevity, in real app parse query params posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), userID, limit, offset) if err != nil { log.Error().Err(err).Msg("Failed to fetch saved posts") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved posts"}) return } // Sign URLs for i := range posts { if posts[i].ImageURL != nil { signed := h.assetService.SignImageURL(*posts[i].ImageURL) posts[i].ImageURL = &signed } if posts[i].VideoURL != nil { signed := h.assetService.SignVideoURL(*posts[i].VideoURL) posts[i].VideoURL = &signed } if posts[i].ThumbnailURL != nil { signed := h.assetService.SignImageURL(*posts[i].ThumbnailURL) posts[i].ThumbnailURL = &signed } } c.JSON(http.StatusOK, gin.H{"posts": posts}) } func (h *UserHandler) GetLikedPosts(c *gin.Context) { userID := c.GetString("user_id") limit := 20 offset := 0 posts, err := h.postRepo.GetLikedPosts(c.Request.Context(), userID, limit, offset) if err != nil { log.Error().Err(err).Msg("Failed to fetch liked posts") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch liked posts"}) return } // Sign URLs for i := range posts { if posts[i].ImageURL != nil { signed := h.assetService.SignImageURL(*posts[i].ImageURL) posts[i].ImageURL = &signed } if posts[i].VideoURL != nil { signed := h.assetService.SignVideoURL(*posts[i].VideoURL) posts[i].VideoURL = &signed } if posts[i].ThumbnailURL != nil { signed := h.assetService.SignImageURL(*posts[i].ThumbnailURL) posts[i].ThumbnailURL = &signed } } c.JSON(http.StatusOK, gin.H{"posts": posts}) } func (h *UserHandler) AcceptFollowRequest(c *gin.Context) { userIdStr, _ := c.Get("user_id") requesterId := c.Param("id") if err := h.repo.AcceptFollowRequest(c.Request.Context(), userIdStr.(string), requesterId); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to accept follow request"}) return } // Harmony & Notifications go func(targetID, actorID string) { // 1. Update Harmony Scores (Mutual gain) _ = h.repo.UpdateHarmonyScore(context.Background(), targetID, 2) _ = h.repo.UpdateHarmonyScore(context.Background(), actorID, 2) // 2. Send Push Notification to requester err := h.pushService.SendPush(context.Background(), actorID, "Request Accepted", "Your follow request was accepted!", map[string]string{ "type": "follow_accepted", "follower_id": targetID, }) if err != nil { log.Error().Err(err).Msg("Failed to send follow acceptance push") } }(userIdStr.(string), requesterId) c.JSON(http.StatusOK, gin.H{"message": "Follow request accepted"}) } func (h *UserHandler) GetPendingFollowRequests(c *gin.Context) { userIdStr, _ := c.Get("user_id") requests, err := h.repo.GetPendingFollowRequests(c.Request.Context(), userIdStr.(string)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch pending follow requests"}) return } // Sign URLs for avatars in requests for i := range requests { if avatar, ok := requests[i]["avatar_url"].(string); ok && avatar != "" { requests[i]["avatar_url"] = h.assetService.SignImageURL(avatar) } } c.JSON(http.StatusOK, gin.H{"requests": requests}) } func (h *UserHandler) RejectFollowRequest(c *gin.Context) { userIdStr, _ := c.Get("user_id") requesterId := c.Param("id") if err := h.repo.RejectFollowRequest(c.Request.Context(), userIdStr.(string), requesterId); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject follow request"}) return } c.JSON(http.StatusOK, gin.H{"message": "Follow request rejected"}) }