Backend: - Add repost_handler.go with full CRUD (create, boost, delete, report, trending, amplification analytics) - Add profile_layout_handler.go for profile widget layout persistence (GET/PUT) - Wire FeedAlgorithmService into main.go as 15-min background score refresh job - Fix follow_handler.go (broken interface, dead query pattern, naming conflict) - Add DB migration for reposts, repost_reports, profile_layouts, post_feed_scores tables - Add engagement count columns to posts table for feed algorithm - Remove stale Supabase comments from auth middleware - Delete cmd/supabase-migrate/ directory (legacy migration tool) Flutter: - Fix all repost_service.dart API paths (were doubling /api/ prefix against base URL) - Rename forceResetBrokenKeys() -> resetIdentityKeys() in E2EE services - Remove dead _forceResetBrokenKeys method from secure_chat_screen.dart - Implement _navigateToProfile(), _navigateToHashtag(), _navigateToUrl() in sojorn_rich_text.dart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
6.9 KiB
Go
256 lines
6.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type FollowHandler struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
func NewFollowHandler(db *pgxpool.Pool) *FollowHandler {
|
|
return &FollowHandler{db: db}
|
|
}
|
|
|
|
// FollowUser — POST /users/:userId/follow
|
|
func (h *FollowHandler) FollowUser(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
targetUserID := c.Param("userId")
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
if userID == targetUserID {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot follow yourself"})
|
|
return
|
|
}
|
|
|
|
_, err := h.db.Exec(context.Background(), `
|
|
INSERT INTO follows (follower_id, following_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (follower_id, following_id) DO NOTHING
|
|
`, userID, targetUserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to follow user"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "followed"})
|
|
}
|
|
|
|
// UnfollowUser — POST /users/:userId/unfollow
|
|
func (h *FollowHandler) UnfollowUser(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
targetUserID := c.Param("userId")
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
_, err := h.db.Exec(context.Background(), `
|
|
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2
|
|
`, userID, targetUserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unfollow user"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
|
|
}
|
|
|
|
// IsFollowing — GET /users/:userId/is-following
|
|
func (h *FollowHandler) IsFollowing(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
targetUserID := c.Param("userId")
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
var isFollowing bool
|
|
err := h.db.QueryRow(context.Background(), `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = $2
|
|
)
|
|
`, userID, targetUserID).Scan(&isFollowing)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check follow status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"is_following": isFollowing})
|
|
}
|
|
|
|
// GetMutualFollowers — GET /users/:userId/mutual-followers
|
|
func (h *FollowHandler) GetMutualFollowers(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
targetUserID := c.Param("userId")
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
rows, err := h.db.Query(context.Background(), `
|
|
SELECT p.id, p.handle, p.display_name, p.avatar_url
|
|
FROM profiles p
|
|
WHERE p.id IN (
|
|
SELECT following_id FROM follows WHERE follower_id = $1
|
|
)
|
|
AND p.id IN (
|
|
SELECT following_id FROM follows WHERE follower_id = $2
|
|
)
|
|
LIMIT 50
|
|
`, userID, targetUserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mutual followers"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type mutualUser struct {
|
|
ID string `json:"id"`
|
|
Handle string `json:"handle"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
users := []mutualUser{}
|
|
for rows.Next() {
|
|
var u mutualUser
|
|
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL); err == nil {
|
|
users = append(users, u)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"mutual_followers": users})
|
|
}
|
|
|
|
// GetSuggestedUsers — GET /users/suggested
|
|
func (h *FollowHandler) GetSuggestedUsers(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
// Suggest users followed by people the current user follows, excluding already-followed
|
|
rows, err := h.db.Query(context.Background(), `
|
|
SELECT DISTINCT p.id, p.handle, p.display_name, p.avatar_url,
|
|
COUNT(f2.follower_id) AS mutual_count
|
|
FROM follows f1
|
|
JOIN follows f2 ON f2.follower_id = f1.following_id
|
|
JOIN profiles p ON p.id = f2.following_id
|
|
WHERE f1.follower_id = $1
|
|
AND f2.following_id != $1
|
|
AND f2.following_id NOT IN (
|
|
SELECT following_id FROM follows WHERE follower_id = $1
|
|
)
|
|
GROUP BY p.id, p.handle, p.display_name, p.avatar_url
|
|
ORDER BY mutual_count DESC
|
|
LIMIT 10
|
|
`, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get suggestions"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type suggestedUser struct {
|
|
ID string `json:"id"`
|
|
Handle string `json:"handle"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
MutualCount int `json:"mutual_count"`
|
|
}
|
|
suggestions := []suggestedUser{}
|
|
for rows.Next() {
|
|
var u suggestedUser
|
|
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.MutualCount); err == nil {
|
|
suggestions = append(suggestions, u)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"suggestions": suggestions})
|
|
}
|
|
|
|
// GetFollowers — GET /users/:userId/followers
|
|
func (h *FollowHandler) GetFollowers(c *gin.Context) {
|
|
targetUserID := c.Param("userId")
|
|
|
|
rows, err := h.db.Query(context.Background(), `
|
|
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
|
FROM follows f
|
|
JOIN profiles p ON f.follower_id = p.id
|
|
WHERE f.following_id = $1
|
|
ORDER BY f.created_at DESC
|
|
LIMIT 100
|
|
`, targetUserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get followers"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type follower struct {
|
|
ID string `json:"id"`
|
|
Handle string `json:"handle"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
FollowedAt string `json:"followed_at"`
|
|
}
|
|
followers := []follower{}
|
|
for rows.Next() {
|
|
var f follower
|
|
var followedAt interface{}
|
|
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
|
|
followers = append(followers, f)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"followers": followers})
|
|
}
|
|
|
|
// GetFollowing — GET /users/:userId/following
|
|
func (h *FollowHandler) GetFollowing(c *gin.Context) {
|
|
targetUserID := c.Param("userId")
|
|
|
|
rows, err := h.db.Query(context.Background(), `
|
|
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
|
FROM follows f
|
|
JOIN profiles p ON f.following_id = p.id
|
|
WHERE f.follower_id = $1
|
|
ORDER BY f.created_at DESC
|
|
LIMIT 100
|
|
`, targetUserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get following"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type followingUser struct {
|
|
ID string `json:"id"`
|
|
Handle string `json:"handle"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
FollowedAt string `json:"followed_at"`
|
|
}
|
|
following := []followingUser{}
|
|
for rows.Next() {
|
|
var f followingUser
|
|
var followedAt interface{}
|
|
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
|
|
following = append(following, f)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"following": following})
|
|
}
|