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:
parent
e1470c8f52
commit
61165000a9
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue