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("/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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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)
|
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