sojorn/go-backend/internal/handlers/user_handler.go
Patrick Britton 61165000a9 Implement social graph, circle privacy, and data export system
Backend Infrastructure:
- Add circle_members table and is_in_circle() SQL function
- Implement GetFollowers/GetFollowing with pagination and trust scores
- Add complete circle management (add/remove/list members)
- Create comprehensive data export for GDPR compliance

API Endpoints:
- GET /users/:id/followers - List user's followers
- GET /users/:id/following - List users they follow
- POST /users/circle/:id - Add to close friends circle
- DELETE /users/circle/:id - Remove from circle
- GET /users/circle/members - List circle members
- GET /users/me/export - Export all user data as JSON

Note: Circle visibility enforcement in feed queries needs manual completion in post_repository.go GetFeed(), GetPostsByAuthor(), and GetPostByID() methods.
2026-02-04 16:19:05 -06:00

605 lines
19 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
notificationService *services.NotificationService
assetService *services.AssetService
}
func NewUserHandler(repo *repository.UserRepository, postRepo *repository.PostRepository, notificationService *services.NotificationService, assetService *services.AssetService) *UserHandler {
return &UserHandler{
repo: repo,
postRepo: postRepo,
notificationService: notificationService,
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 Notification
if h.notificationService != nil {
go func(targetID string, actorID string, isPending bool) {
_ = h.notificationService.NotifyFollow(context.Background(), targetID, actorID, isPending)
}(followingID, followerID.(string), status == "pending")
}
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
if h.notificationService != nil {
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 Notification to requester
_ = h.notificationService.NotifyFollowAccepted(context.Background(), actorID, targetID)
}(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) 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) GetTrustState(c *gin.Context) {
userIDStr, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
state, err := h.repo.GetTrustState(c.Request.Context(), userIDStr.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch trust state"})
return
}
c.JSON(http.StatusOK, state)
}
// ========================================================================
// Social Graph: Followers & Following
// ========================================================================
// GetFollowers returns the list of users following the specified user
func (h *UserHandler) GetFollowers(c *gin.Context) {
targetUserID := c.Param("id")
limit := utils.GetQueryInt(c, "limit", 20)
offset := utils.GetQueryInt(c, "offset", 0)
followers, err := h.repo.GetFollowers(c.Request.Context(), targetUserID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch followers"})
return
}
// Sign avatar URLs
for i := range followers {
if followers[i].AvatarURL != nil {
signed := h.assetService.SignImageURL(*followers[i].AvatarURL)
followers[i].AvatarURL = &signed
}
}
c.JSON(http.StatusOK, gin.H{"followers": followers, "count": len(followers)})
}
// GetFollowing returns the list of users the specified user is following
func (h *UserHandler) GetFollowing(c *gin.Context) {
targetUserID := c.Param("id")
limit := utils.GetQueryInt(c, "limit", 20)
offset := utils.GetQueryInt(c, "offset", 0)
following, err := h.repo.GetFollowing(c.Request.Context(), targetUserID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch following"})
return
}
// Sign avatar URLs
for i := range following {
if following[i].AvatarURL != nil {
signed := h.assetService.SignImageURL(*following[i].AvatarURL)
following[i].AvatarURL = &signed
}
}
c.JSON(http.StatusOK, gin.H{"following": following, "count": len(following)})
}
// ========================================================================
// Circle (Close Friends) Management
// ========================================================================
// AddToCircle adds a user to the current user's circle
func (h *UserHandler) AddToCircle(c *gin.Context) {
userID, _ := c.Get("user_id")
memberID := c.Param("id")
if err := h.repo.AddToCircle(c.Request.Context(), userID.(string), memberID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User added to circle"})
}
// RemoveFromCircle removes a user from the current user's circle
func (h *UserHandler) RemoveFromCircle(c *gin.Context) {
userID, _ := c.Get("user_id")
memberID := c.Param("id")
if err := h.repo.RemoveFromCircle(c.Request.Context(), userID.(string), memberID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove from circle"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User removed from circle"})
}
// GetCircleMembers returns all members of the current user's circle
func (h *UserHandler) GetCircleMembers(c *gin.Context) {
userID, _ := c.Get("user_id")
members, err := h.repo.GetCircleMembers(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch circle members"})
return
}
// Sign avatar URLs
for i := range members {
if members[i].AvatarURL != nil {
signed := h.assetService.SignImageURL(*members[i].AvatarURL)
members[i].AvatarURL = &signed
}
}
c.JSON(http.StatusOK, gin.H{"members": members, "count": len(members)})
}
// ========================================================================
// Data Export (Portability)
// ========================================================================
// ExportData streams user data as JSON for portability/GDPR compliance
func (h *UserHandler) ExportData(c *gin.Context) {
userID, _ := c.Get("user_id")
exportData, err := h.repo.ExportUserData(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate export"})
return
}
// Sign all image/video URLs in posts
for i := range exportData.Posts {
if exportData.Posts[i].ImageURL != nil {
signed := h.assetService.SignImageURL(*exportData.Posts[i].ImageURL)
exportData.Posts[i].ImageURL = &signed
}
if exportData.Posts[i].VideoURL != nil {
signed := h.assetService.SignVideoURL(*exportData.Posts[i].VideoURL)
exportData.Posts[i].VideoURL = &signed
}
}
// Set headers for file download
c.Header("Content-Disposition", "attachment; filename=sojorn_data_export.json")
c.Header("Content-Type", "application/json")
c.JSON(http.StatusOK, exportData)
}