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/patbritton/sojorn-backend/pkg/utils" "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) { currentUserID := c.GetString("user_id") // Authenticated user targetID := c.Param("id") if targetID == "" || targetID == "me" { targetID = currentUserID } // TODO: Add privacy check here if viewing another user's saved posts limit := utils.GetQueryInt(c, "limit", 20) offset := utils.GetQueryInt(c, "offset", 0) posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), targetID, 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"}) } func (h *UserHandler) BlockUser(c *gin.Context) { blockerID, _ := c.Get("user_id") blockedID := c.Param("id") actorIP := c.ClientIP() if err := h.repo.BlockUser(c.Request.Context(), blockerID.(string), blockedID, actorIP); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to block user"}) return } // Also unfollow automatically _ = h.repo.UnfollowUser(c.Request.Context(), blockerID.(string), blockedID) _ = h.repo.UnfollowUser(c.Request.Context(), blockedID, blockerID.(string)) c.JSON(http.StatusOK, gin.H{"message": "User blocked"}) } func (h *UserHandler) ReportUser(c *gin.Context) { reporterID, _ := c.Get("user_id") var input struct { TargetUserID string `json:"target_user_id" binding:"required"` PostID string `json:"post_id"` CommentID string `json:"comment_id"` ViolationType string `json:"violation_type" binding:"required"` Description string `json:"description"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } rID, _ := uuid.Parse(reporterID.(string)) tID, _ := uuid.Parse(input.TargetUserID) report := &models.Report{ ReporterID: rID, TargetUserID: tID, ViolationType: input.ViolationType, Description: input.Description, } if input.PostID != "" { pID, _ := uuid.Parse(input.PostID) report.PostID = &pID } if input.CommentID != "" { cID, _ := uuid.Parse(input.CommentID) report.CommentID = &cID } if err := h.repo.CreateReport(c.Request.Context(), report); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create report"}) return } c.JSON(http.StatusOK, gin.H{"message": "Report submitted successfully"}) } func (h *UserHandler) UnblockUser(c *gin.Context) { blockerID, _ := c.Get("user_id") blockedID := c.Param("id") if err := h.repo.UnblockUser(c.Request.Context(), blockerID.(string), blockedID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unblock user"}) return } c.JSON(http.StatusOK, gin.H{"message": "User unblocked"}) } func (h *UserHandler) GetBlockedUsers(c *gin.Context) { userID, _ := c.Get("user_id") blocked, err := h.repo.GetBlockedUsers(c.Request.Context(), userID.(string)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch blocked users"}) return } // Sign URLs for i := range blocked { if blocked[i].AvatarURL != nil { signed := h.assetService.SignImageURL(*blocked[i].AvatarURL) blocked[i].AvatarURL = &signed } } c.JSON(http.StatusOK, gin.H{"users": blocked}) } func (h *UserHandler) RemoveFCMToken(c *gin.Context) { userID, _ := c.Get("user_id") var input struct { Token string `json:"token" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Token is required"}) return } if err := h.repo.DeleteFCMToken(c.Request.Context(), userID.(string), input.Token); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove token"}) return } c.JSON(http.StatusOK, gin.H{"message": "Token removed successfully"}) } func (h *UserHandler) BlockUserByHandle(c *gin.Context) { actorID, _ := c.Get("user_id") actorIP := c.ClientIP() var input struct { Handle string `json:"handle" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Handle is required"}) return } if err := h.repo.BlockUserByHandle(c.Request.Context(), actorID.(string), input.Handle, actorIP); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to block user"}) return } c.JSON(http.StatusOK, gin.H{"message": "User blocked by handle"}) } func (h *UserHandler) RegisterFCMToken(c *gin.Context) { userID, _ := c.Get("user_id") var input struct { Token string `json:"token" binding:"required"` Platform string `json:"platform" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Token and platform are required"}) return } if err := h.repo.UpsertFCMToken(c.Request.Context(), userID.(string), input.Token, input.Platform); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register token"}) return } c.JSON(http.StatusOK, gin.H{"message": "Token registered successfully"}) }