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.
This commit is contained in:
Patrick Britton 2026-02-04 16:19:05 -06:00
parent e1470c8f52
commit 61165000a9
5 changed files with 403 additions and 0 deletions

View file

@ -213,6 +213,18 @@ func main() {
users.POST("/report", userHandler.ReportUser) users.POST("/report", userHandler.ReportUser)
users.POST("/block_by_handle", userHandler.BlockUserByHandle) 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) authorized.POST("/posts", postHandler.CreatePost)

View file

@ -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;

View file

@ -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;

View file

@ -469,3 +469,136 @@ func (h *UserHandler) GetTrustState(c *gin.Context) {
c.JSON(http.StatusOK, state) 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)
}

View file

@ -1075,3 +1075,232 @@ func (r *UserRepository) BlockUserByHandle(ctx context.Context, actorID string,
} }
return r.BlockUser(ctx, actorID, targetID.String(), actorIP) 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
}