sojorn/go-backend/internal/handlers/user_handler.go

511 lines
15 KiB
Go

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"})
}