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:
Patrick Britton 2026-02-17 14:04:24 -06:00
parent 56a9dd032f
commit c3329a0893
14 changed files with 901 additions and 387 deletions

View file

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

View file

@ -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)
}

View file

@ -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"`
c.JSON(http.StatusOK, gin.H{"mutual_followers": mutualFollowers}) 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)
}
} }
// GetSuggestedUsers returns suggested users to follow c.JSON(http.StatusOK, gin.H{"mutual_followers": users})
}
// 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})
} }

View 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),
})
}

View 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, &amplifies)
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
}

View file

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

View 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);

View file

@ -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.')),

View file

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

View file

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

View file

@ -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,
}); });

View file

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

View file

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

View file

@ -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');
} }
} }