feat: Implement repost/boost API, profile layout persistence, feed algorithm wiring, and legacy cleanup
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>
This commit is contained in:
parent
56a9dd032f
commit
c3329a0893
|
|
@ -213,6 +213,13 @@ func main() {
|
||||||
cfg.R2VidDomain,
|
cfg.R2VidDomain,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Feed algorithm service (scores posts for ranked feed)
|
||||||
|
feedAlgorithmService := services.NewFeedAlgorithmService(dbPool)
|
||||||
|
|
||||||
|
// Repost & profile layout handlers
|
||||||
|
repostHandler := handlers.NewRepostHandler(dbPool)
|
||||||
|
profileLayoutHandler := handlers.NewProfileLayoutHandler(dbPool)
|
||||||
|
|
||||||
r.GET("/ws", wsHandler.ServeWS)
|
r.GET("/ws", wsHandler.ServeWS)
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
|
@ -394,15 +401,12 @@ func main() {
|
||||||
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
|
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
|
||||||
authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag)
|
authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag)
|
||||||
|
|
||||||
// Follow System
|
// Follow System (unique routes only — followers/following covered by users group above)
|
||||||
followHandler := handlers.NewFollowHandler(dbPool)
|
followHandler := handlers.NewFollowHandler(dbPool)
|
||||||
authorized.POST("/users/:userId/follow", followHandler.FollowUser)
|
|
||||||
authorized.POST("/users/:userId/unfollow", followHandler.UnfollowUser)
|
authorized.POST("/users/:userId/unfollow", followHandler.UnfollowUser)
|
||||||
authorized.GET("/users/:userId/is-following", followHandler.IsFollowing)
|
authorized.GET("/users/:userId/is-following", followHandler.IsFollowing)
|
||||||
authorized.GET("/users/:userId/mutual-followers", followHandler.GetMutualFollowers)
|
authorized.GET("/users/:userId/mutual-followers", followHandler.GetMutualFollowers)
|
||||||
authorized.GET("/users/suggested", followHandler.GetSuggestedUsers)
|
authorized.GET("/users/suggested", followHandler.GetSuggestedUsers)
|
||||||
authorized.GET("/users/:userId/followers", followHandler.GetFollowers)
|
|
||||||
authorized.GET("/users/:userId/following", followHandler.GetFollowing)
|
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
||||||
|
|
@ -542,6 +546,24 @@ func main() {
|
||||||
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
|
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repost & amplification system
|
||||||
|
authorized.POST("/posts/repost", repostHandler.CreateRepost)
|
||||||
|
authorized.POST("/posts/boost", repostHandler.BoostPost)
|
||||||
|
authorized.GET("/posts/trending", repostHandler.GetTrendingPosts)
|
||||||
|
authorized.GET("/posts/:id/reposts", repostHandler.GetRepostsForPost)
|
||||||
|
authorized.GET("/posts/:id/amplification", repostHandler.GetAmplificationAnalytics)
|
||||||
|
authorized.POST("/posts/:id/calculate-score", repostHandler.CalculateAmplificationScore)
|
||||||
|
authorized.DELETE("/reposts/:id", repostHandler.DeleteRepost)
|
||||||
|
authorized.POST("/reposts/:id/report", repostHandler.ReportRepost)
|
||||||
|
authorized.GET("/amplification/rules", repostHandler.GetAmplificationRules)
|
||||||
|
authorized.GET("/users/:id/reposts", repostHandler.GetUserReposts)
|
||||||
|
authorized.GET("/users/:id/can-boost/:postId", repostHandler.CanBoostPost)
|
||||||
|
authorized.GET("/users/:id/daily-boosts", repostHandler.GetDailyBoostCount)
|
||||||
|
|
||||||
|
// Profile widget layout
|
||||||
|
authorized.GET("/profile/layout", profileLayoutHandler.GetProfileLayout)
|
||||||
|
authorized.PUT("/profile/layout", profileLayoutHandler.SaveProfileLayout)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,6 +718,18 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Background job: update feed algorithm scores every 15 minutes
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
log.Debug().Msg("[FeedAlgorithm] Refreshing feed scores")
|
||||||
|
if err := feedAlgorithmService.UpdateFeedScores(context.Background(), []string{}, ""); err != nil {
|
||||||
|
log.Error().Err(err).Msg("[FeedAlgorithm] Failed to refresh feed scores")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Background job: purge accounts past 14-day deletion window (runs every hour)
|
// Background job: purge accounts past 14-day deletion window (runs every hour)
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SupabaseProfile struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Bio *string `json:"bio"`
|
|
||||||
AvatarURL *string `json:"avatar_url"`
|
|
||||||
IsOfficial bool `json:"is_official"`
|
|
||||||
IsPrivate bool `json:"is_private"`
|
|
||||||
BeaconEnabled bool `json:"beacon_enabled"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SupabasePost struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
AuthorID string `json:"author_id"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
ImageURL *string `json:"image_url"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
CategoryID *string `json:"category_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Visibility string `json:"visibility"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
godotenv.Load()
|
|
||||||
|
|
||||||
dbURL := os.Getenv("DATABASE_URL")
|
|
||||||
sbURL := os.Getenv("SUPABASE_URL")
|
|
||||||
sbKey := os.Getenv("SUPABASE_KEY")
|
|
||||||
|
|
||||||
if dbURL == "" || sbURL == "" || sbKey == "" {
|
|
||||||
log.Fatal("Missing env vars: DATABASE_URL, SUPABASE_URL, or SUPABASE_KEY")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to Local DB
|
|
||||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 1. Fetch Profiles
|
|
||||||
log.Println("Fetching profiles from Supabase...")
|
|
||||||
var profiles []SupabaseProfile
|
|
||||||
if err := fetchSupabase(sbURL, sbKey, "profiles", &profiles); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
log.Printf("Found %d profiles", len(profiles))
|
|
||||||
|
|
||||||
// 2. Insert Profiles (and Users if needed)
|
|
||||||
for _, p := range profiles {
|
|
||||||
// Ensure User Exists
|
|
||||||
var exists bool
|
|
||||||
pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", p.ID).Scan(&exists)
|
|
||||||
if !exists {
|
|
||||||
// Create placeholder user
|
|
||||||
placeholderEmail := fmt.Sprintf("imported_%s@sojorn.com", p.ID[:8])
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
INSERT INTO users (id, email, encrypted_password, created_at)
|
|
||||||
VALUES ($1, $2, 'placeholder_hash', $3)
|
|
||||||
`, p.ID, placeholderEmail, p.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to create user for profile %s: %v", p.Handle, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert Profile
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
INSERT INTO profiles (id, handle, display_name, bio, avatar_url, is_official, is_private, beacon_enabled, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
handle = EXCLUDED.handle,
|
|
||||||
display_name = EXCLUDED.display_name,
|
|
||||||
bio = EXCLUDED.bio,
|
|
||||||
avatar_url = EXCLUDED.avatar_url,
|
|
||||||
is_private = EXCLUDED.is_private
|
|
||||||
`, p.ID, p.Handle, p.DisplayName, p.Bio, p.AvatarURL, p.IsOfficial, p.IsPrivate, p.BeaconEnabled, p.CreatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to import profile %s: %v", p.Handle, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fetch Posts
|
|
||||||
log.Println("Fetching posts from Supabase...")
|
|
||||||
var posts []SupabasePost
|
|
||||||
if err := fetchSupabase(sbURL, sbKey, "posts", &posts); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
log.Printf("Found %d posts", len(posts))
|
|
||||||
|
|
||||||
// 4. Insert Posts
|
|
||||||
for _, p := range posts {
|
|
||||||
// Default values if missing
|
|
||||||
status := "active"
|
|
||||||
if p.Status != "" {
|
|
||||||
status = p.Status
|
|
||||||
}
|
|
||||||
visibility := "public"
|
|
||||||
if p.Visibility != "" {
|
|
||||||
visibility = p.Visibility
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
INSERT INTO posts (id, author_id, body, image_url, category_id, status, visibility, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
`, p.ID, p.AuthorID, p.Body, p.ImageURL, p.CategoryID, status, visibility, p.CreatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to import post %s: %v", p.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Migration complete.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchSupabase(url, key, table string, target interface{}) error {
|
|
||||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/rest/v1/%s?select=*", url, table), nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("apikey", key)
|
|
||||||
req.Header.Add("Authorization", "Bearer "+key)
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("supabase API error (%d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.NewDecoder(resp.Body).Decode(target)
|
|
||||||
}
|
|
||||||
|
|
@ -1,235 +1,255 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FollowHandler struct {
|
type FollowHandler struct {
|
||||||
db interface {
|
db *pgxpool.Pool
|
||||||
Exec(query string, args ...interface{}) (interface{}, error)
|
|
||||||
Query(query string, args ...interface{}) (interface{}, error)
|
|
||||||
QueryRow(query string, args ...interface{}) interface{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFollowHandler(db interface {
|
func NewFollowHandler(db *pgxpool.Pool) *FollowHandler {
|
||||||
Exec(query string, args ...interface{}) (interface{}, error)
|
|
||||||
Query(query string, args ...interface{}) (interface{}, error)
|
|
||||||
QueryRow(query string, args ...interface{}) interface{}
|
|
||||||
}) *FollowHandler {
|
|
||||||
return &FollowHandler{db: db}
|
return &FollowHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FollowUser creates a follow relationship
|
// FollowUser — POST /users/:userId/follow
|
||||||
func (h *FollowHandler) FollowUser(c *gin.Context) {
|
func (h *FollowHandler) FollowUser(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetUserID := c.Param("userId")
|
targetUserID := c.Param("userId")
|
||||||
if targetUserID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if userID == targetUserID {
|
if userID == targetUserID {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot follow yourself"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot follow yourself"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
_, err := h.db.Exec(context.Background(), `
|
||||||
INSERT INTO follows (follower_id, following_id)
|
INSERT INTO follows (follower_id, following_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
ON CONFLICT (follower_id, following_id) DO NOTHING
|
ON CONFLICT (follower_id, following_id) DO NOTHING
|
||||||
RETURNING id
|
`, userID, targetUserID)
|
||||||
`
|
|
||||||
|
|
||||||
var followID string
|
|
||||||
err := h.db.QueryRow(query, userID, targetUserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow user"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to follow user"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{"message": "followed"})
|
||||||
"message": "Successfully followed user",
|
|
||||||
"follow_id": followID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnfollowUser removes a follow relationship
|
// UnfollowUser — POST /users/:userId/unfollow
|
||||||
func (h *FollowHandler) UnfollowUser(c *gin.Context) {
|
func (h *FollowHandler) UnfollowUser(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetUserID := c.Param("userId")
|
targetUserID := c.Param("userId")
|
||||||
if targetUserID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
_, err := h.db.Exec(context.Background(), `
|
||||||
DELETE FROM follows
|
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2
|
||||||
WHERE follower_id = $1 AND following_id = $2
|
`, userID, targetUserID)
|
||||||
`
|
|
||||||
|
|
||||||
_, err := h.db.Exec(query, userID, targetUserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow user"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unfollow user"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Successfully unfollowed user"})
|
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsFollowing checks if current user follows target user
|
// IsFollowing — GET /users/:userId/is-following
|
||||||
func (h *FollowHandler) IsFollowing(c *gin.Context) {
|
func (h *FollowHandler) IsFollowing(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetUserID := c.Param("userId")
|
targetUserID := c.Param("userId")
|
||||||
if targetUserID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM follows
|
|
||||||
WHERE follower_id = $1 AND following_id = $2
|
|
||||||
)
|
|
||||||
`
|
|
||||||
|
|
||||||
var isFollowing bool
|
var isFollowing bool
|
||||||
err := h.db.QueryRow(query, userID, targetUserID)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check follow status"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check follow status"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"is_following": isFollowing})
|
c.JSON(http.StatusOK, gin.H{"is_following": isFollowing})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMutualFollowers returns users that both current user and target user follow
|
// GetMutualFollowers — GET /users/:userId/mutual-followers
|
||||||
func (h *FollowHandler) GetMutualFollowers(c *gin.Context) {
|
func (h *FollowHandler) GetMutualFollowers(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetUserID := c.Param("userId")
|
targetUserID := c.Param("userId")
|
||||||
if targetUserID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `SELECT * FROM get_mutual_followers($1, $2)`
|
rows, err := h.db.Query(context.Background(), `
|
||||||
|
SELECT p.id, p.handle, p.display_name, p.avatar_url
|
||||||
rows, err := h.db.Query(query, userID, targetUserID)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get mutual followers"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mutual followers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
var mutualFollowers []map[string]interface{}
|
type mutualUser struct {
|
||||||
// Parse rows into mutualFollowers slice
|
ID string `json:"id"`
|
||||||
// Implementation depends on your DB driver
|
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": mutualFollowers})
|
c.JSON(http.StatusOK, gin.H{"mutual_followers": users})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSuggestedUsers returns suggested users to follow
|
// GetSuggestedUsers — GET /users/suggested
|
||||||
func (h *FollowHandler) GetSuggestedUsers(c *gin.Context) {
|
func (h *FollowHandler) GetSuggestedUsers(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := 10
|
// Suggest users followed by people the current user follows, excluding already-followed
|
||||||
if limitParam := c.Query("limit"); limitParam != "" {
|
rows, err := h.db.Query(context.Background(), `
|
||||||
// Parse limit from query param
|
SELECT DISTINCT p.id, p.handle, p.display_name, p.avatar_url,
|
||||||
}
|
COUNT(f2.follower_id) AS mutual_count
|
||||||
|
FROM follows f1
|
||||||
query := `SELECT * FROM get_suggested_users($1, $2)`
|
JOIN follows f2 ON f2.follower_id = f1.following_id
|
||||||
|
JOIN profiles p ON p.id = f2.following_id
|
||||||
rows, err := h.db.Query(query, userID, limit)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get suggestions"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
var suggestions []map[string]interface{}
|
type suggestedUser struct {
|
||||||
// Parse rows into suggestions slice
|
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})
|
c.JSON(http.StatusOK, gin.H{"suggestions": suggestions})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFollowers returns list of users following the target user
|
// GetFollowers — GET /users/:userId/followers
|
||||||
func (h *FollowHandler) GetFollowers(c *gin.Context) {
|
func (h *FollowHandler) GetFollowers(c *gin.Context) {
|
||||||
targetUserID := c.Param("userId")
|
targetUserID := c.Param("userId")
|
||||||
if targetUserID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
rows, err := h.db.Query(context.Background(), `
|
||||||
SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at
|
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
||||||
FROM follows f
|
FROM follows f
|
||||||
JOIN profiles p ON f.follower_id = p.user_id
|
JOIN profiles p ON f.follower_id = p.id
|
||||||
WHERE f.following_id = $1
|
WHERE f.following_id = $1
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
`
|
`, targetUserID)
|
||||||
|
|
||||||
rows, err := h.db.Query(query, targetUserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get followers"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get followers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
var followers []map[string]interface{}
|
type follower struct {
|
||||||
// Parse rows
|
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})
|
c.JSON(http.StatusOK, gin.H{"followers": followers})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFollowing returns list of users that target user follows
|
// GetFollowing — GET /users/:userId/following
|
||||||
func (h *FollowHandler) GetFollowing(c *gin.Context) {
|
func (h *FollowHandler) GetFollowing(c *gin.Context) {
|
||||||
targetUserID := c.Param("userId")
|
targetUserID := c.Param("userId")
|
||||||
if targetUserID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
rows, err := h.db.Query(context.Background(), `
|
||||||
SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at
|
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
||||||
FROM follows f
|
FROM follows f
|
||||||
JOIN profiles p ON f.following_id = p.user_id
|
JOIN profiles p ON f.following_id = p.id
|
||||||
WHERE f.follower_id = $1
|
WHERE f.follower_id = $1
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
`
|
`, targetUserID)
|
||||||
|
|
||||||
rows, err := h.db.Query(query, targetUserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get following"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get following"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
var following []map[string]interface{}
|
type followingUser struct {
|
||||||
// Parse rows
|
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})
|
c.JSON(http.StatusOK, gin.H{"following": following})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
go-backend/internal/handlers/profile_layout_handler.go
Normal file
111
go-backend/internal/handlers/profile_layout_handler.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProfileLayoutHandler struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProfileLayoutHandler(db *pgxpool.Pool) *ProfileLayoutHandler {
|
||||||
|
return &ProfileLayoutHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfileLayout — GET /profile/layout
|
||||||
|
func (h *ProfileLayoutHandler) GetProfileLayout(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
userIDStr := userID.(string)
|
||||||
|
|
||||||
|
var widgetsJSON []byte
|
||||||
|
var theme string
|
||||||
|
var accentColor, bannerImageURL *string
|
||||||
|
var updatedAt time.Time
|
||||||
|
|
||||||
|
err := h.db.QueryRow(c.Request.Context(), `
|
||||||
|
SELECT widgets, theme, accent_color, banner_image_url, updated_at
|
||||||
|
FROM profile_layouts
|
||||||
|
WHERE user_id = $1
|
||||||
|
`, userIDStr).Scan(&widgetsJSON, &theme, &accentColor, &bannerImageURL, &updatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// No layout yet — return empty default
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"widgets": []interface{}{},
|
||||||
|
"theme": "default",
|
||||||
|
"accent_color": nil,
|
||||||
|
"banner_image_url": nil,
|
||||||
|
"updated_at": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var widgets interface{}
|
||||||
|
if err := json.Unmarshal(widgetsJSON, &widgets); err != nil {
|
||||||
|
widgets = []interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"widgets": widgets,
|
||||||
|
"theme": theme,
|
||||||
|
"accent_color": accentColor,
|
||||||
|
"banner_image_url": bannerImageURL,
|
||||||
|
"updated_at": updatedAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveProfileLayout — PUT /profile/layout
|
||||||
|
func (h *ProfileLayoutHandler) SaveProfileLayout(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
userIDStr := userID.(string)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Widgets interface{} `json:"widgets"`
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
AccentColor *string `json:"accent_color"`
|
||||||
|
BannerImageURL *string `json:"banner_image_url"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Theme == "" {
|
||||||
|
req.Theme = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
widgetsJSON, err := json.Marshal(req.Widgets)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid widgets format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err = h.db.Exec(c.Request.Context(), `
|
||||||
|
INSERT INTO profile_layouts (user_id, widgets, theme, accent_color, banner_image_url, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
widgets = EXCLUDED.widgets,
|
||||||
|
theme = EXCLUDED.theme,
|
||||||
|
accent_color = EXCLUDED.accent_color,
|
||||||
|
banner_image_url = EXCLUDED.banner_image_url,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`, userIDStr, widgetsJSON, req.Theme, req.AccentColor, req.BannerImageURL, now)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save layout"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"widgets": req.Widgets,
|
||||||
|
"theme": req.Theme,
|
||||||
|
"accent_color": req.AccentColor,
|
||||||
|
"banner_image_url": req.BannerImageURL,
|
||||||
|
"updated_at": now.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
502
go-backend/internal/handlers/repost_handler.go
Normal file
502
go-backend/internal/handlers/repost_handler.go
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepostHandler struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepostHandler(db *pgxpool.Pool) *RepostHandler {
|
||||||
|
return &RepostHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepost — POST /posts/repost
|
||||||
|
func (h *RepostHandler) CreateRepost(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
userIDStr := userID.(string)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
OriginalPostID string `json:"original_post_id" binding:"required"`
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validTypes := map[string]bool{"standard": true, "quote": true, "boost": true, "amplify": true}
|
||||||
|
if !validTypes[req.Type] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repost type"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorHandle string
|
||||||
|
var avatarURL *string
|
||||||
|
err := h.db.QueryRow(c.Request.Context(),
|
||||||
|
"SELECT handle, avatar_url FROM profiles WHERE id = $1", userIDStr,
|
||||||
|
).Scan(&authorHandle, &avatarURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user info"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
now := time.Now()
|
||||||
|
_, err = h.db.Exec(c.Request.Context(), `
|
||||||
|
INSERT INTO reposts (id, original_post_id, author_id, type, comment, metadata, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
|
||||||
|
`, id, req.OriginalPostID, userIDStr, req.Type, req.Comment, req.Metadata, now)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create repost"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
countCol := repostCountColumn(req.Type)
|
||||||
|
h.db.Exec(c.Request.Context(),
|
||||||
|
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
|
||||||
|
req.OriginalPostID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"repost": gin.H{
|
||||||
|
"id": id,
|
||||||
|
"original_post_id": req.OriginalPostID,
|
||||||
|
"author_id": userIDStr,
|
||||||
|
"author_handle": authorHandle,
|
||||||
|
"author_avatar": avatarURL,
|
||||||
|
"type": req.Type,
|
||||||
|
"comment": req.Comment,
|
||||||
|
"created_at": now.Format(time.RFC3339),
|
||||||
|
"boost_count": 0,
|
||||||
|
"amplification_score": 0,
|
||||||
|
"is_amplified": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoostPost — POST /posts/boost
|
||||||
|
func (h *RepostHandler) BoostPost(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
userIDStr := userID.(string)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
PostID string `json:"post_id" binding:"required"`
|
||||||
|
BoostType string `json:"boost_type" binding:"required"`
|
||||||
|
BoostAmount int `json:"boost_amount"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.BoostAmount <= 0 {
|
||||||
|
req.BoostAmount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDaily := 5
|
||||||
|
if req.BoostType == "amplify" {
|
||||||
|
maxDaily = 3
|
||||||
|
}
|
||||||
|
var dailyCount int
|
||||||
|
h.db.QueryRow(c.Request.Context(), `
|
||||||
|
SELECT COUNT(*) FROM reposts
|
||||||
|
WHERE author_id = $1 AND type = $2 AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
`, userIDStr, req.BoostType).Scan(&dailyCount)
|
||||||
|
if dailyCount >= maxDaily {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "daily boost limit reached", "success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
_, err := h.db.Exec(c.Request.Context(), `
|
||||||
|
INSERT INTO reposts (id, original_post_id, author_id, type, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
|
||||||
|
`, id, req.PostID, userIDStr, req.BoostType)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to boost post"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
countCol := repostCountColumn(req.BoostType)
|
||||||
|
h.db.Exec(c.Request.Context(),
|
||||||
|
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
|
||||||
|
req.PostID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepostsForPost — GET /posts/:id/reposts
|
||||||
|
func (h *RepostHandler) GetRepostsForPost(c *gin.Context) {
|
||||||
|
postID := c.Param("id")
|
||||||
|
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
|
||||||
|
|
||||||
|
rows, err := h.db.Query(c.Request.Context(), `
|
||||||
|
SELECT r.id, r.original_post_id, r.author_id,
|
||||||
|
p.handle, p.avatar_url,
|
||||||
|
r.type, r.comment, r.created_at
|
||||||
|
FROM reposts r
|
||||||
|
JOIN profiles p ON p.id = r.author_id
|
||||||
|
WHERE r.original_post_id = $1
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, postID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get reposts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
reposts := buildRepostList(rows)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserReposts — GET /users/:id/reposts
|
||||||
|
func (h *RepostHandler) GetUserReposts(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
|
||||||
|
|
||||||
|
rows, err := h.db.Query(c.Request.Context(), `
|
||||||
|
SELECT r.id, r.original_post_id, r.author_id,
|
||||||
|
p.handle, p.avatar_url,
|
||||||
|
r.type, r.comment, r.created_at
|
||||||
|
FROM reposts r
|
||||||
|
JOIN profiles p ON p.id = r.author_id
|
||||||
|
WHERE r.author_id = $1
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user reposts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
reposts := buildRepostList(rows)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepost — DELETE /reposts/:id
|
||||||
|
func (h *RepostHandler) DeleteRepost(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
repostID := c.Param("id")
|
||||||
|
|
||||||
|
var origPostID, repostType string
|
||||||
|
err := h.db.QueryRow(c.Request.Context(),
|
||||||
|
"SELECT original_post_id, type FROM reposts WHERE id = $1 AND author_id = $2",
|
||||||
|
repostID, userID.(string),
|
||||||
|
).Scan(&origPostID, &repostType)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "repost not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.Exec(c.Request.Context(),
|
||||||
|
"DELETE FROM reposts WHERE id = $1 AND author_id = $2",
|
||||||
|
repostID, userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete repost"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
countCol := repostCountColumn(repostType)
|
||||||
|
h.db.Exec(c.Request.Context(),
|
||||||
|
"UPDATE posts SET "+countCol+" = GREATEST("+countCol+" - 1, 0) WHERE id = $1",
|
||||||
|
origPostID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmplificationAnalytics — GET /posts/:id/amplification
|
||||||
|
func (h *RepostHandler) GetAmplificationAnalytics(c *gin.Context) {
|
||||||
|
postID := c.Param("id")
|
||||||
|
|
||||||
|
var totalAmplification int
|
||||||
|
h.db.QueryRow(c.Request.Context(),
|
||||||
|
"SELECT COUNT(*) FROM reposts WHERE original_post_id = $1", postID,
|
||||||
|
).Scan(&totalAmplification)
|
||||||
|
|
||||||
|
var viewCount int
|
||||||
|
h.db.QueryRow(c.Request.Context(),
|
||||||
|
"SELECT COALESCE(view_count, 1) FROM posts WHERE id = $1", postID,
|
||||||
|
).Scan(&viewCount)
|
||||||
|
if viewCount == 0 {
|
||||||
|
viewCount = 1
|
||||||
|
}
|
||||||
|
amplificationRate := float64(totalAmplification) / float64(viewCount)
|
||||||
|
|
||||||
|
rows, _ := h.db.Query(c.Request.Context(),
|
||||||
|
"SELECT type, COUNT(*) FROM reposts WHERE original_post_id = $1 GROUP BY type", postID)
|
||||||
|
repostCounts := map[string]int{}
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
var cnt int
|
||||||
|
rows.Scan(&t, &cnt)
|
||||||
|
repostCounts[t] = cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"analytics": gin.H{
|
||||||
|
"post_id": postID,
|
||||||
|
"metrics": []gin.H{},
|
||||||
|
"reposts": []gin.H{},
|
||||||
|
"total_amplification": totalAmplification,
|
||||||
|
"amplification_rate": amplificationRate,
|
||||||
|
"repost_counts": repostCounts,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrendingPosts — GET /posts/trending
|
||||||
|
func (h *RepostHandler) GetTrendingPosts(c *gin.Context) {
|
||||||
|
limit := clampInt(queryInt(c, "limit", 10), 1, 50)
|
||||||
|
category := c.Query("category")
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT p.id
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.status = 'active'
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
`
|
||||||
|
args := []interface{}{}
|
||||||
|
argIdx := 1
|
||||||
|
|
||||||
|
if category != "" {
|
||||||
|
query += " AND p.category = $" + strconv.Itoa(argIdx)
|
||||||
|
args = append(args, category)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
ORDER BY (
|
||||||
|
COALESCE(p.like_count, 0) * 1 +
|
||||||
|
COALESCE(p.comment_count, 0) * 3 +
|
||||||
|
COALESCE(p.repost_count, 0) * 4 +
|
||||||
|
COALESCE(p.boost_count, 0) * 8 +
|
||||||
|
COALESCE(p.amplify_count, 0) * 10
|
||||||
|
) DESC, p.created_at DESC
|
||||||
|
LIMIT $` + strconv.Itoa(argIdx)
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := h.db.Query(c.Request.Context(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get trending posts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var postIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
rows.Scan(&id)
|
||||||
|
postIDs = append(postIDs, id)
|
||||||
|
}
|
||||||
|
if postIDs == nil {
|
||||||
|
postIDs = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "posts": postIDs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmplificationRules — GET /amplification/rules
|
||||||
|
func (h *RepostHandler) GetAmplificationRules(c *gin.Context) {
|
||||||
|
rules := []gin.H{
|
||||||
|
{
|
||||||
|
"id": "rule-standard", "name": "Standard Repost",
|
||||||
|
"description": "Share a post with your followers",
|
||||||
|
"type": "standard", "weight_multiplier": 1.0,
|
||||||
|
"min_boost_score": 0, "max_daily_boosts": 20,
|
||||||
|
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rule-quote", "name": "Quote Repost",
|
||||||
|
"description": "Share a post with your commentary",
|
||||||
|
"type": "quote", "weight_multiplier": 1.5,
|
||||||
|
"min_boost_score": 0, "max_daily_boosts": 10,
|
||||||
|
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rule-boost", "name": "Boost",
|
||||||
|
"description": "Amplify a post's reach in the feed",
|
||||||
|
"type": "boost", "weight_multiplier": 8.0,
|
||||||
|
"min_boost_score": 0, "max_daily_boosts": 5,
|
||||||
|
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rule-amplify", "name": "Amplify",
|
||||||
|
"description": "Maximum amplification for high-quality content",
|
||||||
|
"type": "amplify", "weight_multiplier": 10.0,
|
||||||
|
"min_boost_score": 100, "max_daily_boosts": 3,
|
||||||
|
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateAmplificationScore — POST /posts/:id/calculate-score
|
||||||
|
func (h *RepostHandler) CalculateAmplificationScore(c *gin.Context) {
|
||||||
|
postID := c.Param("id")
|
||||||
|
|
||||||
|
var likes, comments, reposts, boosts, amplifies int
|
||||||
|
h.db.QueryRow(c.Request.Context(), `
|
||||||
|
SELECT COALESCE(like_count,0), COALESCE(comment_count,0),
|
||||||
|
COALESCE(repost_count,0), COALESCE(boost_count,0), COALESCE(amplify_count,0)
|
||||||
|
FROM posts WHERE id = $1
|
||||||
|
`, postID).Scan(&likes, &comments, &reposts, &boosts, &lifies)
|
||||||
|
|
||||||
|
score := likes*1 + comments*3 + reposts*4 + boosts*8 + amplifies*10
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "score": score})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanBoostPost — GET /users/:id/can-boost/:postId
|
||||||
|
func (h *RepostHandler) CanBoostPost(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
postID := c.Param("postId")
|
||||||
|
boostType := c.Query("type")
|
||||||
|
|
||||||
|
var alreadyBoosted int
|
||||||
|
h.db.QueryRow(c.Request.Context(),
|
||||||
|
"SELECT COUNT(*) FROM reposts WHERE author_id=$1 AND original_post_id=$2 AND type=$3",
|
||||||
|
userID, postID, boostType,
|
||||||
|
).Scan(&alreadyBoosted)
|
||||||
|
if alreadyBoosted > 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"can_boost": false, "reason": "already_boosted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDaily := 5
|
||||||
|
if boostType == "amplify" {
|
||||||
|
maxDaily = 3
|
||||||
|
}
|
||||||
|
var dailyCount int
|
||||||
|
h.db.QueryRow(c.Request.Context(), `
|
||||||
|
SELECT COUNT(*) FROM reposts
|
||||||
|
WHERE author_id=$1 AND type=$2 AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
`, userID, boostType).Scan(&dailyCount)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"can_boost": dailyCount < maxDaily})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDailyBoostCount — GET /users/:id/daily-boosts
|
||||||
|
func (h *RepostHandler) GetDailyBoostCount(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
rows, err := h.db.Query(c.Request.Context(), `
|
||||||
|
SELECT type, COUNT(*) FROM reposts
|
||||||
|
WHERE author_id=$1 AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
GROUP BY type
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get boost counts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
boostCounts := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
var cnt int
|
||||||
|
rows.Scan(&t, &cnt)
|
||||||
|
boostCounts[t] = cnt
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "boost_counts": boostCounts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportRepost — POST /reposts/:id/report
|
||||||
|
func (h *RepostHandler) ReportRepost(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
repostID := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.db.Exec(c.Request.Context(), `
|
||||||
|
INSERT INTO repost_reports (id, repost_id, reporter_id, reason, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (repost_id, reporter_id) DO NOTHING
|
||||||
|
`, uuid.New().String(), repostID, userID.(string), req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to report repost"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func repostCountColumn(repostType string) string {
|
||||||
|
switch repostType {
|
||||||
|
case "boost":
|
||||||
|
return "boost_count"
|
||||||
|
case "amplify":
|
||||||
|
return "amplify_count"
|
||||||
|
default:
|
||||||
|
return "repost_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryInt(c *gin.Context, key string, def int) int {
|
||||||
|
if s := c.Query(key); s != "" {
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(v, min, max int) int {
|
||||||
|
if v < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if v > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRepostList(rows interface {
|
||||||
|
Next() bool
|
||||||
|
Scan(...interface{}) error
|
||||||
|
Close()
|
||||||
|
}) []gin.H {
|
||||||
|
list := []gin.H{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, origPostID, authorID, handle, repostType string
|
||||||
|
var avatarURL, comment *string
|
||||||
|
var createdAt time.Time
|
||||||
|
rows.Scan(&id, &origPostID, &authorID, &handle, &avatarURL, &repostType, &comment, &createdAt)
|
||||||
|
list = append(list, gin.H{
|
||||||
|
"id": id,
|
||||||
|
"original_post_id": origPostID,
|
||||||
|
"author_id": authorID,
|
||||||
|
"author_handle": handle,
|
||||||
|
"author_avatar": avatarURL,
|
||||||
|
"type": repostType,
|
||||||
|
"comment": comment,
|
||||||
|
"created_at": createdAt.Format(time.RFC3339),
|
||||||
|
"boost_count": 0,
|
||||||
|
"amplification_score": 0,
|
||||||
|
"is_amplified": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,6 @@ import (
|
||||||
|
|
||||||
func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, error) {
|
func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, error) {
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
// Validate the algorithm (Supabase uses HS256 usually)
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +30,6 @@ func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, er
|
||||||
return "", nil, fmt.Errorf("invalid token claims")
|
return "", nil, fmt.Errorf("invalid token claims")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supabase uses 'sub' field for user ID
|
|
||||||
userID, ok := claims["sub"].(string)
|
userID, ok := claims["sub"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", nil, fmt.Errorf("token missing user ID")
|
return "", nil, fmt.Errorf("token missing user ID")
|
||||||
|
|
|
||||||
75
go-backend/migrations/20260217_repost_and_layout.sql
Normal file
75
go-backend/migrations/20260217_repost_and_layout.sql
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
-- Migration: Add reposts, profile_layouts, and post_feed_scores tables
|
||||||
|
-- Also adds engagement count columns to posts for feed algorithm
|
||||||
|
|
||||||
|
-- ─── Engagement columns on posts ──────────────────────────────────────────────
|
||||||
|
ALTER TABLE public.posts
|
||||||
|
ADD COLUMN IF NOT EXISTS like_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS share_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS repost_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS boost_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS amplify_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS video_url TEXT;
|
||||||
|
|
||||||
|
-- Backfill existing like/comment/view counts from post_metrics
|
||||||
|
UPDATE public.posts p
|
||||||
|
SET
|
||||||
|
like_count = COALESCE(m.like_count, 0),
|
||||||
|
comment_count = COALESCE(m.comment_count, 0),
|
||||||
|
view_count = COALESCE(m.view_count, 0)
|
||||||
|
FROM public.post_metrics m
|
||||||
|
WHERE p.id = m.post_id;
|
||||||
|
|
||||||
|
-- ─── Reposts ──────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.reposts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
original_post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||||
|
author_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('standard', 'quote', 'boost', 'amplify')),
|
||||||
|
comment TEXT,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One repost per type per user per post
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_reposts_unique
|
||||||
|
ON public.reposts (original_post_id, author_id, type);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reposts_original_post_id ON public.reposts (original_post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reposts_author_id ON public.reposts (author_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reposts_created_at ON public.reposts (created_at DESC);
|
||||||
|
|
||||||
|
-- ─── Repost reports ───────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.repost_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
repost_id UUID NOT NULL REFERENCES public.reposts(id) ON DELETE CASCADE,
|
||||||
|
reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (repost_id, reporter_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ─── Profile widget layouts ───────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.profile_layouts (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
widgets JSONB NOT NULL DEFAULT '[]',
|
||||||
|
theme VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||||
|
accent_color VARCHAR(20),
|
||||||
|
banner_image_url TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ─── Post feed scores (feed algorithm) ───────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.post_feed_scores (
|
||||||
|
post_id UUID PRIMARY KEY REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||||
|
score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
engagement_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
quality_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
recency_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
network_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
personalization DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_feed_scores_score ON public.post_feed_scores (score DESC);
|
||||||
|
|
@ -833,7 +833,7 @@ class _ChatDataManagementScreenState extends State<ChatDataManagementScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
await _e2ee.forceResetBrokenKeys();
|
await _e2ee.resetIdentityKeys();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Encryption keys reset. New identity generated.')),
|
const SnackBar(content: Text('Encryption keys reset. New identity generated.')),
|
||||||
|
|
|
||||||
|
|
@ -1050,56 +1050,6 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
||||||
_chatService.markMessageLocallyDeleted(messageId);
|
_chatService.markMessageLocallyDeleted(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _forceResetBrokenKeys() async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Force Reset All Keys?'),
|
|
||||||
content: const Text(
|
|
||||||
'This will DELETE all encryption keys and generate fresh 256-bit keys. '
|
|
||||||
'This fixes the 208-bit key bug that causes MAC errors. '
|
|
||||||
'All existing messages will become undecryptable.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: SojornColors.destructive),
|
|
||||||
child: const Text('Force Reset'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
|
||||||
try {
|
|
||||||
await _chatService.forceResetBrokenKeys();
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Keys force reset! Restart chat to test.'),
|
|
||||||
backgroundColor: SojornColors.destructive,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Error resetting keys: $e'),
|
|
||||||
backgroundColor: AppTheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Widget _buildInputArea() {
|
Widget _buildInputArea() {
|
||||||
return ComposerWidget(
|
return ComposerWidget(
|
||||||
controller: _messageController,
|
controller: _messageController,
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class _EncryptionHubScreenState extends State<EncryptionHubScreen> {
|
||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
final e2ee = SimpleE2EEService();
|
final e2ee = SimpleE2EEService();
|
||||||
await e2ee.forceResetBrokenKeys();
|
await e2ee.resetIdentityKeys();
|
||||||
await _loadStatus();
|
await _loadStatus();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class RepostService {
|
||||||
Map<String, dynamic>? metadata,
|
Map<String, dynamic>? metadata,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.post('/api/posts/repost', {
|
final response = await ApiService.instance.post('/posts/repost', {
|
||||||
'original_post_id': originalPostId,
|
'original_post_id': originalPostId,
|
||||||
'type': type.name,
|
'type': type.name,
|
||||||
'comment': comment,
|
'comment': comment,
|
||||||
|
|
@ -41,7 +41,7 @@ class RepostService {
|
||||||
int? boostAmount,
|
int? boostAmount,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.post('/api/posts/boost', {
|
final response = await ApiService.instance.post('/posts/boost', {
|
||||||
'post_id': postId,
|
'post_id': postId,
|
||||||
'boost_type': boostType.name,
|
'boost_type': boostType.name,
|
||||||
'boost_amount': boostAmount ?? 1,
|
'boost_amount': boostAmount ?? 1,
|
||||||
|
|
@ -57,7 +57,7 @@ class RepostService {
|
||||||
/// Get all reposts for a post
|
/// Get all reposts for a post
|
||||||
static Future<List<Repost>> getRepostsForPost(String postId) async {
|
static Future<List<Repost>> getRepostsForPost(String postId) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.get('/api/posts/$postId/reposts');
|
final response = await ApiService.instance.get('/posts/$postId/reposts');
|
||||||
|
|
||||||
if (response['success'] == true) {
|
if (response['success'] == true) {
|
||||||
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
||||||
|
|
@ -72,7 +72,7 @@ class RepostService {
|
||||||
/// Get user's repost history
|
/// Get user's repost history
|
||||||
static Future<List<Repost>> getUserReposts(String userId, {int limit = 20}) async {
|
static Future<List<Repost>> getUserReposts(String userId, {int limit = 20}) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.get('/api/users/$userId/reposts?limit=$limit');
|
final response = await ApiService.instance.get('/users/$userId/reposts?limit=$limit');
|
||||||
|
|
||||||
if (response['success'] == true) {
|
if (response['success'] == true) {
|
||||||
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
||||||
|
|
@ -87,7 +87,7 @@ class RepostService {
|
||||||
/// Delete a repost
|
/// Delete a repost
|
||||||
static Future<bool> deleteRepost(String repostId) async {
|
static Future<bool> deleteRepost(String repostId) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.delete('/api/reposts/$repostId');
|
final response = await ApiService.instance.delete('/reposts/$repostId');
|
||||||
return response['success'] == true;
|
return response['success'] == true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting repost: $e');
|
print('Error deleting repost: $e');
|
||||||
|
|
@ -98,7 +98,7 @@ class RepostService {
|
||||||
/// Get amplification analytics for a post
|
/// Get amplification analytics for a post
|
||||||
static Future<AmplificationAnalytics?> getAmplificationAnalytics(String postId) async {
|
static Future<AmplificationAnalytics?> getAmplificationAnalytics(String postId) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.get('/api/posts/$postId/amplification');
|
final response = await ApiService.instance.get('/posts/$postId/amplification');
|
||||||
|
|
||||||
if (response['success'] == true) {
|
if (response['success'] == true) {
|
||||||
return AmplificationAnalytics.fromJson(response['analytics']);
|
return AmplificationAnalytics.fromJson(response['analytics']);
|
||||||
|
|
@ -112,7 +112,7 @@ class RepostService {
|
||||||
/// Get trending posts based on amplification
|
/// Get trending posts based on amplification
|
||||||
static Future<List<Post>> getTrendingPosts({int limit = 10, String? category}) async {
|
static Future<List<Post>> getTrendingPosts({int limit = 10, String? category}) async {
|
||||||
try {
|
try {
|
||||||
String url = '/api/posts/trending?limit=$limit';
|
String url = '/posts/trending?limit=$limit';
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
url += '&category=$category';
|
url += '&category=$category';
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +132,7 @@ class RepostService {
|
||||||
/// Get amplification rules
|
/// Get amplification rules
|
||||||
static Future<List<FeedAmplificationRule>> getAmplificationRules() async {
|
static Future<List<FeedAmplificationRule>> getAmplificationRules() async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.get('/api/amplification/rules');
|
final response = await ApiService.instance.get('/amplification/rules');
|
||||||
|
|
||||||
if (response['success'] == true) {
|
if (response['success'] == true) {
|
||||||
final rulesData = response['rules'] as List<dynamic>? ?? [];
|
final rulesData = response['rules'] as List<dynamic>? ?? [];
|
||||||
|
|
@ -147,7 +147,7 @@ class RepostService {
|
||||||
/// Calculate amplification score for a post
|
/// Calculate amplification score for a post
|
||||||
static Future<int> calculateAmplificationScore(String postId) async {
|
static Future<int> calculateAmplificationScore(String postId) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.post('/api/posts/$postId/calculate-score', {});
|
final response = await ApiService.instance.post('/posts/$postId/calculate-score', {});
|
||||||
|
|
||||||
if (response['success'] == true) {
|
if (response['success'] == true) {
|
||||||
return response['score'] as int? ?? 0;
|
return response['score'] as int? ?? 0;
|
||||||
|
|
@ -161,7 +161,7 @@ class RepostService {
|
||||||
/// Check if user can boost a post
|
/// Check if user can boost a post
|
||||||
static Future<bool> canBoostPost(String userId, String postId, RepostType boostType) async {
|
static Future<bool> canBoostPost(String userId, String postId, RepostType boostType) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.get('/api/users/$userId/can-boost/$postId?type=${boostType.name}');
|
final response = await ApiService.instance.get('/users/$userId/can-boost/$postId?type=${boostType.name}');
|
||||||
|
|
||||||
return response['can_boost'] == true;
|
return response['can_boost'] == true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -173,7 +173,7 @@ class RepostService {
|
||||||
/// Get user's daily boost count
|
/// Get user's daily boost count
|
||||||
static Future<Map<RepostType, int>> getDailyBoostCount(String userId) async {
|
static Future<Map<RepostType, int>> getDailyBoostCount(String userId) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.get('/api/users/$userId/daily-boosts');
|
final response = await ApiService.instance.get('/users/$userId/daily-boosts');
|
||||||
|
|
||||||
if (response['success'] == true) {
|
if (response['success'] == true) {
|
||||||
final boostCounts = response['boost_counts'] as Map<String, dynamic>? ?? {};
|
final boostCounts = response['boost_counts'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
@ -195,7 +195,7 @@ class RepostService {
|
||||||
/// Report inappropriate repost
|
/// Report inappropriate repost
|
||||||
static Future<bool> reportRepost(String repostId, String reason) async {
|
static Future<bool> reportRepost(String repostId, String reason) async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.instance.post('/api/reposts/$repostId/report', {
|
final response = await ApiService.instance.post('/reposts/$repostId/report', {
|
||||||
'reason': reason,
|
'reason': reason,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,8 @@ class SecureChatService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force reset to fix 208-bit key bug
|
Future<void> resetIdentityKeys() async {
|
||||||
Future<void> forceResetBrokenKeys() async {
|
await _e2ee.resetIdentityKeys();
|
||||||
await _e2ee.forceResetBrokenKeys();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual key upload for testing
|
// Manual key upload for testing
|
||||||
|
|
|
||||||
|
|
@ -96,36 +96,18 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force reset to fix 208-bit key bug
|
// Reset all local encryption keys and generate a fresh identity.
|
||||||
Future<void> forceResetBrokenKeys() async {
|
// Existing encrypted messages will become undecryptable after this.
|
||||||
|
Future<void> resetIdentityKeys() async {
|
||||||
// Clear ALL storage completely
|
|
||||||
await _storage.deleteAll();
|
await _storage.deleteAll();
|
||||||
|
|
||||||
// Clear local key variables
|
|
||||||
_identityDhKeyPair = null;
|
_identityDhKeyPair = null;
|
||||||
_identitySigningKeyPair = null;
|
_identitySigningKeyPair = null;
|
||||||
_signedPreKey = null;
|
_signedPreKey = null;
|
||||||
_oneTimePreKeys = null;
|
_oneTimePreKeys = null;
|
||||||
_initializedForUserId = null;
|
_initializedForUserId = null;
|
||||||
_initFuture = null;
|
_initFuture = null;
|
||||||
|
|
||||||
// Clear session cache
|
|
||||||
_sessionCache.clear();
|
_sessionCache.clear();
|
||||||
|
|
||||||
|
|
||||||
// Generate fresh identity with proper key lengths
|
|
||||||
await generateNewIdentity();
|
await generateNewIdentity();
|
||||||
|
|
||||||
// Verify the new keys are proper length
|
|
||||||
if (_identityDhKeyPair != null) {
|
|
||||||
final publicKey = await _identityDhKeyPair!.extractPublicKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_identitySigningKeyPair != null) {
|
|
||||||
final publicKey = await _identitySigningKeyPair!.extractPublicKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual key upload for testing
|
// Manual key upload for testing
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../utils/link_handler.dart';
|
import '../utils/link_handler.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
import '../screens/discover/discover_screen.dart';
|
import '../screens/discover/discover_screen.dart';
|
||||||
|
|
||||||
/// Rich text widget that automatically detects and styles URLs and mentions.
|
/// Rich text widget that automatically detects and styles URLs and mentions.
|
||||||
|
|
@ -107,11 +108,11 @@ class sojornRichText extends StatelessWidget {
|
||||||
recognizer: TapGestureRecognizer()
|
recognizer: TapGestureRecognizer()
|
||||||
..onTap = () {
|
..onTap = () {
|
||||||
if (isMention) {
|
if (isMention) {
|
||||||
_navigateToProfile(matchText);
|
_navigateToProfile(context, matchText);
|
||||||
} else if (isHashtag) {
|
} else if (isHashtag) {
|
||||||
_navigateToHashtag(matchText);
|
_navigateToHashtag(context, matchText);
|
||||||
} else {
|
} else {
|
||||||
_navigateToUrl(matchText);
|
_navigateToUrl(context, matchText);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -144,23 +145,22 @@ class sojornRichText extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToProfile(String username) {
|
void _navigateToProfile(BuildContext context, String username) {
|
||||||
// Remove @ prefix if present
|
|
||||||
final cleanUsername = username.startsWith('@') ? username.substring(1) : username;
|
final cleanUsername = username.startsWith('@') ? username.substring(1) : username;
|
||||||
// Navigate to profile screen
|
AppRoutes.navigateToProfile(context, cleanUsername);
|
||||||
// This would typically use GoRouter or Navigator
|
|
||||||
print('Navigate to profile: $cleanUsername');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToHashtag(String hashtag) {
|
void _navigateToHashtag(BuildContext context, String hashtag) {
|
||||||
// Remove # prefix if present
|
|
||||||
final cleanHashtag = hashtag.startsWith('#') ? hashtag.substring(1) : hashtag;
|
final cleanHashtag = hashtag.startsWith('#') ? hashtag.substring(1) : hashtag;
|
||||||
// Navigate to search/discover with hashtag
|
Navigator.push(
|
||||||
print('Navigate to hashtag: $cleanHashtag');
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DiscoverScreen(initialQuery: '#$cleanHashtag'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToUrl(String url) {
|
void _navigateToUrl(BuildContext context, String url) {
|
||||||
// Launch URL in browser or handle in-app
|
LinkHandler.launchLink(context, url);
|
||||||
print('Navigate to URL: $url');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue