diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index cfc2523..7f1ee2a 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -213,6 +213,18 @@ func main() { users.POST("/report", userHandler.ReportUser) users.POST("/block_by_handle", userHandler.BlockUserByHandle) + // Social Graph: Followers & Following + users.GET("/:id/followers", userHandler.GetFollowers) + users.GET("/:id/following", userHandler.GetFollowing) + + // Circle Management + users.POST("/circle/:id", userHandler.AddToCircle) + users.DELETE("/circle/:id", userHandler.RemoveFromCircle) + users.GET("/circle/members", userHandler.GetCircleMembers) + + // Data Export + users.GET("/me/export", userHandler.ExportData) + } authorized.POST("/posts", postHandler.CreatePost) diff --git a/go-backend/internal/database/migrations/20260204000002_circle_privacy.down.sql b/go-backend/internal/database/migrations/20260204000002_circle_privacy.down.sql new file mode 100644 index 0000000..83245e5 --- /dev/null +++ b/go-backend/internal/database/migrations/20260204000002_circle_privacy.down.sql @@ -0,0 +1,3 @@ +-- Rollback circle privacy feature +DROP FUNCTION IF EXISTS public.is_in_circle(UUID, UUID); +DROP TABLE IF EXISTS public.circle_members; diff --git a/go-backend/internal/database/migrations/20260204000002_circle_privacy.up.sql b/go-backend/internal/database/migrations/20260204000002_circle_privacy.up.sql new file mode 100644 index 0000000..888f785 --- /dev/null +++ b/go-backend/internal/database/migrations/20260204000002_circle_privacy.up.sql @@ -0,0 +1,26 @@ +-- Circle (Close Friends) Privacy Feature +-- Allows users to share posts only with a specific inner circle + +CREATE TABLE IF NOT EXISTS public.circle_members ( + user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, member_id), + -- Prevent self-addition + CHECK (user_id != member_id) +); + +-- Index for fast circle membership checks +CREATE INDEX IF NOT EXISTS idx_circle_members_user_id ON public.circle_members(user_id); +CREATE INDEX IF NOT EXISTS idx_circle_members_member_id ON public.circle_members(member_id); + +-- Helper function to check if a user is in another user's circle +CREATE OR REPLACE FUNCTION public.is_in_circle(circle_owner_id UUID, potential_member_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.circle_members + WHERE user_id = circle_owner_id AND member_id = potential_member_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE; diff --git a/go-backend/internal/handlers/user_handler.go b/go-backend/internal/handlers/user_handler.go index e675d1f..a525b7a 100644 --- a/go-backend/internal/handlers/user_handler.go +++ b/go-backend/internal/handlers/user_handler.go @@ -469,3 +469,136 @@ func (h *UserHandler) GetTrustState(c *gin.Context) { 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) +} diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index 6686dbb..20effbd 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -1075,3 +1075,232 @@ func (r *UserRepository) BlockUserByHandle(ctx context.Context, actorID string, } return r.BlockUser(ctx, actorID, targetID.String(), actorIP) } + +// ======================================================================== +// Social Graph: Followers & Following Lists +// ======================================================================== + +type FollowerUser struct { + ID uuid.UUID `json:"id"` + Handle string `json:"handle"` + DisplayName string `json:"display_name"` + AvatarURL *string `json:"avatar_url"` + HarmonyScore int `json:"harmony_score"` + Tier string `json:"tier"` + FollowedAt time.Time `json:"followed_at"` +} + +// GetFollowers returns a list of users following the specified user +func (r *UserRepository) GetFollowers(ctx context.Context, userID string, limit, offset int) ([]FollowerUser, error) { + query := ` + SELECT + p.id, p.handle, p.display_name, p.avatar_url, + COALESCE(t.harmony_score, 50) as harmony_score, + COALESCE(t.tier, 'new') as tier, + f.created_at as followed_at + FROM public.follows f + JOIN public.profiles p ON p.id = f.follower_id + LEFT JOIN public.trust_state t ON t.user_id = p.id + WHERE f.following_id = $1::uuid AND f.status = 'accepted' + ORDER BY f.created_at DESC + LIMIT $2 OFFSET $3 + ` + rows, err := r.pool.Query(ctx, query, userID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var followers []FollowerUser + for rows.Next() { + var f FollowerUser + if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &f.HarmonyScore, &f.Tier, &f.FollowedAt); err != nil { + return nil, err + } + followers = append(followers, f) + } + return followers, nil +} + +// GetFollowing returns a list of users the specified user is following +func (r *UserRepository) GetFollowing(ctx context.Context, userID string, limit, offset int) ([]FollowerUser, error) { + query := ` + SELECT + p.id, p.handle, p.display_name, p.avatar_url, + COALESCE(t.harmony_score, 50) as harmony_score, + COALESCE(t.tier, 'new') as tier, + f.created_at as followed_at + FROM public.follows f + JOIN public.profiles p ON p.id = f.following_id + LEFT JOIN public.trust_state t ON t.user_id = p.id + WHERE f.follower_id = $1::uuid AND f.status = 'accepted' + ORDER BY f.created_at DESC + LIMIT $2 OFFSET $3 + ` + rows, err := r.pool.Query(ctx, query, userID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var following []FollowerUser + for rows.Next() { + var f FollowerUser + if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &f.HarmonyScore, &f.Tier, &f.FollowedAt); err != nil { + return nil, err + } + following = append(following, f) + } + return following, nil +} + +// ======================================================================== +// Circle (Close Friends) Management +// ======================================================================== + +// AddToCircle adds a user to the current user's circle +func (r *UserRepository) AddToCircle(ctx context.Context, userID, memberID string) error { + // Verify that the user follows the member first + isFollowing, err := r.IsFollowing(ctx, userID, memberID) + if err != nil { + return err + } + if !isFollowing { + return fmt.Errorf("can only add users you follow to your circle") + } + + query := ` + INSERT INTO public.circle_members (user_id, member_id) + VALUES ($1::uuid, $2::uuid) + ON CONFLICT DO NOTHING + ` + _, err = r.pool.Exec(ctx, query, userID, memberID) + return err +} + +// RemoveFromCircle removes a user from the current user's circle +func (r *UserRepository) RemoveFromCircle(ctx context.Context, userID, memberID string) error { + query := `DELETE FROM public.circle_members WHERE user_id = $1::uuid AND member_id = $2::uuid` + _, err := r.pool.Exec(ctx, query, userID, memberID) + return err +} + +// GetCircleMembers returns all users in the current user's circle +func (r *UserRepository) GetCircleMembers(ctx context.Context, userID string) ([]FollowerUser, error) { + query := ` + SELECT + p.id, p.handle, p.display_name, p.avatar_url, + COALESCE(t.harmony_score, 50) as harmony_score, + COALESCE(t.tier, 'new') as tier, + c.created_at as followed_at + FROM public.circle_members c + JOIN public.profiles p ON p.id = c.member_id + LEFT JOIN public.trust_state t ON t.user_id = p.id + WHERE c.user_id = $1::uuid + ORDER BY c.created_at DESC + ` + rows, err := r.pool.Query(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []FollowerUser + for rows.Next() { + var m FollowerUser + if err := rows.Scan(&m.ID, &m.Handle, &m.DisplayName, &m.AvatarURL, &m.HarmonyScore, &m.Tier, &m.FollowedAt); err != nil { + return nil, err + } + members = append(members, m) + } + return members, nil +} + +// IsInCircle checks if a user is in another user's circle +func (r *UserRepository) IsInCircle(ctx context.Context, ownerID, userID string) (bool, error) { + var exists bool + query := `SELECT EXISTS(SELECT 1 FROM public.circle_members WHERE user_id = $1::uuid AND member_id = $2::uuid)` + err := r.pool.QueryRow(ctx, query, ownerID, userID).Scan(&exists) + return exists, err +} + +// ======================================================================== +// Data Export (Portability) +// ======================================================================== + +type UserExportData struct { + Profile *models.Profile `json:"profile"` + Posts []ExportedPost `json:"posts"` + Following []ExportedFollow `json:"following"` +} + +type ExportedPost struct { + ID uuid.UUID `json:"id"` + Body string `json:"body"` + ImageURL *string `json:"image_url,omitempty"` + VideoURL *string `json:"video_url,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ExportedFollow struct { + Handle string `json:"handle"` + DisplayName string `json:"display_name"` + FollowedAt time.Time `json:"followed_at"` +} + +// ExportUserData generates complete user data export for portability +func (r *UserRepository) ExportUserData(ctx context.Context, userID string) (*UserExportData, error) { + export := &UserExportData{} + + // 1. Get Profile + profile, err := r.GetProfileByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get profile: %w", err) + } + export.Profile = profile + + // 2. Get All Posts + postQuery := ` + SELECT id, body, image_url, video_url, created_at + FROM public.posts + WHERE author_id = $1::uuid AND deleted_at IS NULL + ORDER BY created_at DESC + ` + rows, err := r.pool.Query(ctx, postQuery, userID) + if err != nil { + return nil, fmt.Errorf("failed to get posts: %w", err) + } + defer rows.Close() + + for rows.Next() { + var p ExportedPost + if err := rows.Scan(&p.ID, &p.Body, &p.ImageURL, &p.VideoURL, &p.CreatedAt); err != nil { + return nil, err + } + export.Posts = append(export.Posts, p) + } + + // 3. Get Following List + followQuery := ` + SELECT p.handle, p.display_name, f.created_at + FROM public.follows f + JOIN public.profiles p ON p.id = f.following_id + WHERE f.follower_id = $1::uuid AND f.status = 'accepted' + ORDER BY f.created_at DESC + ` + rows, err = r.pool.Query(ctx, followQuery, userID) + if err != nil { + return nil, fmt.Errorf("failed to get following list: %w", err) + } + defer rows.Close() + + for rows.Next() { + var f ExportedFollow + if err := rows.Scan(&f.Handle, &f.DisplayName, &f.FollowedAt); err != nil { + return nil, err + } + export.Following = append(export.Following, f) + } + + return export, nil +}