diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 6e00f42..d46f9ac 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -213,6 +213,13 @@ func main() { 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("/health", func(c *gin.Context) { @@ -394,15 +401,12 @@ func main() { authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag) 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) - authorized.POST("/users/:userId/follow", followHandler.FollowUser) authorized.POST("/users/:userId/unfollow", followHandler.UnfollowUser) authorized.GET("/users/:userId/is-following", followHandler.IsFollowing) authorized.GET("/users/:userId/mutual-followers", followHandler.GetMutualFollowers) authorized.GET("/users/suggested", followHandler.GetSuggestedUsers) - authorized.GET("/users/:userId/followers", followHandler.GetFollowers) - authorized.GET("/users/:userId/following", followHandler.GetFollowing) // Notifications notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService) @@ -542,6 +546,24 @@ func main() { 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) go func() { ticker := time.NewTicker(1 * time.Hour) diff --git a/go-backend/cmd/supabase-migrate/main.go b/go-backend/cmd/supabase-migrate/main.go deleted file mode 100644 index 11c9bda..0000000 --- a/go-backend/cmd/supabase-migrate/main.go +++ /dev/null @@ -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) -} diff --git a/go-backend/internal/handlers/follow_handler.go b/go-backend/internal/handlers/follow_handler.go index 4bc2921..702cfcc 100644 --- a/go-backend/internal/handlers/follow_handler.go +++ b/go-backend/internal/handlers/follow_handler.go @@ -1,235 +1,255 @@ package handlers import ( + "context" "net/http" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) type FollowHandler struct { - db interface { - Exec(query string, args ...interface{}) (interface{}, error) - Query(query string, args ...interface{}) (interface{}, error) - QueryRow(query string, args ...interface{}) interface{} - } + db *pgxpool.Pool } -func NewFollowHandler(db interface { - Exec(query string, args ...interface{}) (interface{}, error) - Query(query string, args ...interface{}) (interface{}, error) - QueryRow(query string, args ...interface{}) interface{} -}) *FollowHandler { +func NewFollowHandler(db *pgxpool.Pool) *FollowHandler { return &FollowHandler{db: db} } -// FollowUser creates a follow relationship +// FollowUser — POST /users/:userId/follow func (h *FollowHandler) FollowUser(c *gin.Context) { userID := c.GetString("user_id") - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - 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 } - if userID == targetUserID { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot follow yourself"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot follow yourself"}) return } - query := ` + _, err := h.db.Exec(context.Background(), ` INSERT INTO follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT (follower_id, following_id) DO NOTHING - RETURNING id - ` - - var followID string - err := h.db.QueryRow(query, userID, targetUserID) + `, userID, targetUserID) 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 } - c.JSON(http.StatusOK, gin.H{ - "message": "Successfully followed user", - "follow_id": followID, - }) + c.JSON(http.StatusOK, gin.H{"message": "followed"}) } -// UnfollowUser removes a follow relationship +// UnfollowUser — POST /users/:userId/unfollow func (h *FollowHandler) UnfollowUser(c *gin.Context) { userID := c.GetString("user_id") - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - 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 } - query := ` - DELETE FROM follows - WHERE follower_id = $1 AND following_id = $2 - ` - - _, err := h.db.Exec(query, userID, targetUserID) + _, err := h.db.Exec(context.Background(), ` + DELETE FROM follows WHERE follower_id = $1 AND following_id = $2 + `, userID, targetUserID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow user"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unfollow user"}) 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) { userID := c.GetString("user_id") - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - 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 } - query := ` - SELECT EXISTS( - SELECT 1 FROM follows - WHERE follower_id = $1 AND following_id = $2 - ) - ` - 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 { - 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 } 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) { userID := c.GetString("user_id") - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - 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 } - query := `SELECT * FROM get_mutual_followers($1, $2)` - - rows, err := h.db.Query(query, userID, targetUserID) + rows, err := h.db.Query(context.Background(), ` + SELECT p.id, p.handle, p.display_name, p.avatar_url + FROM profiles p + WHERE p.id IN ( + SELECT following_id FROM follows WHERE follower_id = $1 + ) + AND p.id IN ( + SELECT following_id FROM follows WHERE follower_id = $2 + ) + LIMIT 50 + `, userID, targetUserID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get mutual followers"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mutual followers"}) return } + defer rows.Close() - var mutualFollowers []map[string]interface{} - // Parse rows into mutualFollowers slice - // Implementation depends on your DB driver + type mutualUser struct { + ID string `json:"id"` + Handle string `json:"handle"` + DisplayName string `json:"display_name"` + AvatarURL *string `json:"avatar_url"` + } + users := []mutualUser{} + for rows.Next() { + var u mutualUser + if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL); err == nil { + users = append(users, u) + } + } - c.JSON(http.StatusOK, gin.H{"mutual_followers": 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) { userID := c.GetString("user_id") if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } - limit := 10 - if limitParam := c.Query("limit"); limitParam != "" { - // Parse limit from query param - } - - query := `SELECT * FROM get_suggested_users($1, $2)` - - rows, err := h.db.Query(query, userID, limit) + // Suggest users followed by people the current user follows, excluding already-followed + rows, err := h.db.Query(context.Background(), ` + SELECT DISTINCT p.id, p.handle, p.display_name, p.avatar_url, + COUNT(f2.follower_id) AS mutual_count + FROM follows f1 + JOIN follows f2 ON f2.follower_id = f1.following_id + JOIN profiles p ON p.id = f2.following_id + WHERE f1.follower_id = $1 + AND f2.following_id != $1 + AND f2.following_id NOT IN ( + SELECT following_id FROM follows WHERE follower_id = $1 + ) + GROUP BY p.id, p.handle, p.display_name, p.avatar_url + ORDER BY mutual_count DESC + LIMIT 10 + `, userID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get suggestions"}) return } + defer rows.Close() - var suggestions []map[string]interface{} - // Parse rows into suggestions slice + type suggestedUser struct { + ID string `json:"id"` + Handle string `json:"handle"` + DisplayName string `json:"display_name"` + AvatarURL *string `json:"avatar_url"` + MutualCount int `json:"mutual_count"` + } + suggestions := []suggestedUser{} + for rows.Next() { + var u suggestedUser + if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.MutualCount); err == nil { + suggestions = append(suggestions, u) + } + } c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) } -// GetFollowers returns list of users following the target user +// GetFollowers — GET /users/:userId/followers func (h *FollowHandler) GetFollowers(c *gin.Context) { targetUserID := c.Param("userId") - if targetUserID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"}) - return - } - query := ` - SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at + rows, err := h.db.Query(context.Background(), ` + SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at FROM follows f - JOIN profiles p ON f.follower_id = p.user_id + JOIN profiles p ON f.follower_id = p.id WHERE f.following_id = $1 ORDER BY f.created_at DESC LIMIT 100 - ` - - rows, err := h.db.Query(query, targetUserID) + `, targetUserID) 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 } + defer rows.Close() - var followers []map[string]interface{} - // Parse rows + type follower struct { + ID string `json:"id"` + Handle string `json:"handle"` + DisplayName string `json:"display_name"` + AvatarURL *string `json:"avatar_url"` + FollowedAt string `json:"followed_at"` + } + followers := []follower{} + for rows.Next() { + var f follower + var followedAt interface{} + if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil { + followers = append(followers, f) + } + } c.JSON(http.StatusOK, gin.H{"followers": followers}) } -// GetFollowing returns list of users that target user follows +// GetFollowing — GET /users/:userId/following func (h *FollowHandler) GetFollowing(c *gin.Context) { targetUserID := c.Param("userId") - if targetUserID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"}) - return - } - query := ` - SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at + rows, err := h.db.Query(context.Background(), ` + SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at FROM follows f - JOIN profiles p ON f.following_id = p.user_id + JOIN profiles p ON f.following_id = p.id WHERE f.follower_id = $1 ORDER BY f.created_at DESC LIMIT 100 - ` - - rows, err := h.db.Query(query, targetUserID) + `, targetUserID) 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 } + defer rows.Close() - var following []map[string]interface{} - // Parse rows + type followingUser struct { + ID string `json:"id"` + Handle string `json:"handle"` + DisplayName string `json:"display_name"` + AvatarURL *string `json:"avatar_url"` + FollowedAt string `json:"followed_at"` + } + following := []followingUser{} + for rows.Next() { + var f followingUser + var followedAt interface{} + if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil { + following = append(following, f) + } + } c.JSON(http.StatusOK, gin.H{"following": following}) } diff --git a/go-backend/internal/handlers/profile_layout_handler.go b/go-backend/internal/handlers/profile_layout_handler.go new file mode 100644 index 0000000..aa4e31b --- /dev/null +++ b/go-backend/internal/handlers/profile_layout_handler.go @@ -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), + }) +} diff --git a/go-backend/internal/handlers/repost_handler.go b/go-backend/internal/handlers/repost_handler.go new file mode 100644 index 0000000..0067645 --- /dev/null +++ b/go-backend/internal/handlers/repost_handler.go @@ -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 +} diff --git a/go-backend/internal/middleware/auth.go b/go-backend/internal/middleware/auth.go index 9386ae5..76bb4a5 100644 --- a/go-backend/internal/middleware/auth.go +++ b/go-backend/internal/middleware/auth.go @@ -15,7 +15,6 @@ import ( func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, 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 { 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") } - // Supabase uses 'sub' field for user ID userID, ok := claims["sub"].(string) if !ok { return "", nil, fmt.Errorf("token missing user ID") diff --git a/go-backend/migrations/20260217_repost_and_layout.sql b/go-backend/migrations/20260217_repost_and_layout.sql new file mode 100644 index 0000000..a33d6de --- /dev/null +++ b/go-backend/migrations/20260217_repost_and_layout.sql @@ -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); diff --git a/sojorn_app/lib/screens/secure_chat/chat_data_management_screen.dart b/sojorn_app/lib/screens/secure_chat/chat_data_management_screen.dart index 19e3bad..44f56bf 100644 --- a/sojorn_app/lib/screens/secure_chat/chat_data_management_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/chat_data_management_screen.dart @@ -833,7 +833,7 @@ class _ChatDataManagementScreenState extends State { ); if (confirmed != true) return; - await _e2ee.forceResetBrokenKeys(); + await _e2ee.resetIdentityKeys(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Encryption keys reset. New identity generated.')), diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart index 1e8a6c8..789cbc8 100644 --- a/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart @@ -1050,56 +1050,6 @@ class _SecureChatScreenState extends State _chatService.markMessageLocallyDeleted(messageId); } - Future _forceResetBrokenKeys() async { - final confirmed = await showDialog( - 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() { return ComposerWidget( controller: _messageController, diff --git a/sojorn_app/lib/screens/security/encryption_hub_screen.dart b/sojorn_app/lib/screens/security/encryption_hub_screen.dart index fd8b82a..13dd7b5 100644 --- a/sojorn_app/lib/screens/security/encryption_hub_screen.dart +++ b/sojorn_app/lib/screens/security/encryption_hub_screen.dart @@ -84,7 +84,7 @@ class _EncryptionHubScreenState extends State { ); if (confirmed != true) return; final e2ee = SimpleE2EEService(); - await e2ee.forceResetBrokenKeys(); + await e2ee.resetIdentityKeys(); await _loadStatus(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/sojorn_app/lib/services/repost_service.dart b/sojorn_app/lib/services/repost_service.dart index 76f62a5..6541013 100644 --- a/sojorn_app/lib/services/repost_service.dart +++ b/sojorn_app/lib/services/repost_service.dart @@ -18,7 +18,7 @@ class RepostService { Map? metadata, }) async { try { - final response = await ApiService.instance.post('/api/posts/repost', { + final response = await ApiService.instance.post('/posts/repost', { 'original_post_id': originalPostId, 'type': type.name, 'comment': comment, @@ -41,7 +41,7 @@ class RepostService { int? boostAmount, }) async { try { - final response = await ApiService.instance.post('/api/posts/boost', { + final response = await ApiService.instance.post('/posts/boost', { 'post_id': postId, 'boost_type': boostType.name, 'boost_amount': boostAmount ?? 1, @@ -57,7 +57,7 @@ class RepostService { /// Get all reposts for a post static Future> getRepostsForPost(String postId) async { try { - final response = await ApiService.instance.get('/api/posts/$postId/reposts'); + final response = await ApiService.instance.get('/posts/$postId/reposts'); if (response['success'] == true) { final repostsData = response['reposts'] as List? ?? []; @@ -72,7 +72,7 @@ class RepostService { /// Get user's repost history static Future> getUserReposts(String userId, {int limit = 20}) async { 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) { final repostsData = response['reposts'] as List? ?? []; @@ -87,7 +87,7 @@ class RepostService { /// Delete a repost static Future deleteRepost(String repostId) async { try { - final response = await ApiService.instance.delete('/api/reposts/$repostId'); + final response = await ApiService.instance.delete('/reposts/$repostId'); return response['success'] == true; } catch (e) { print('Error deleting repost: $e'); @@ -98,7 +98,7 @@ class RepostService { /// Get amplification analytics for a post static Future getAmplificationAnalytics(String postId) async { try { - final response = await ApiService.instance.get('/api/posts/$postId/amplification'); + final response = await ApiService.instance.get('/posts/$postId/amplification'); if (response['success'] == true) { return AmplificationAnalytics.fromJson(response['analytics']); @@ -112,7 +112,7 @@ class RepostService { /// Get trending posts based on amplification static Future> getTrendingPosts({int limit = 10, String? category}) async { try { - String url = '/api/posts/trending?limit=$limit'; + String url = '/posts/trending?limit=$limit'; if (category != null) { url += '&category=$category'; } @@ -132,7 +132,7 @@ class RepostService { /// Get amplification rules static Future> getAmplificationRules() async { try { - final response = await ApiService.instance.get('/api/amplification/rules'); + final response = await ApiService.instance.get('/amplification/rules'); if (response['success'] == true) { final rulesData = response['rules'] as List? ?? []; @@ -147,7 +147,7 @@ class RepostService { /// Calculate amplification score for a post static Future calculateAmplificationScore(String postId) async { 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) { return response['score'] as int? ?? 0; @@ -161,7 +161,7 @@ class RepostService { /// Check if user can boost a post static Future canBoostPost(String userId, String postId, RepostType boostType) async { 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; } catch (e) { @@ -173,7 +173,7 @@ class RepostService { /// Get user's daily boost count static Future> getDailyBoostCount(String userId) async { 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) { final boostCounts = response['boost_counts'] as Map? ?? {}; @@ -195,7 +195,7 @@ class RepostService { /// Report inappropriate repost static Future reportRepost(String repostId, String reason) async { try { - final response = await ApiService.instance.post('/api/reposts/$repostId/report', { + final response = await ApiService.instance.post('/reposts/$repostId/report', { 'reason': reason, }); diff --git a/sojorn_app/lib/services/secure_chat_service.dart b/sojorn_app/lib/services/secure_chat_service.dart index 45b10e3..7610ae3 100644 --- a/sojorn_app/lib/services/secure_chat_service.dart +++ b/sojorn_app/lib/services/secure_chat_service.dart @@ -81,9 +81,8 @@ class SecureChatService { } } - // Force reset to fix 208-bit key bug - Future forceResetBrokenKeys() async { - await _e2ee.forceResetBrokenKeys(); + Future resetIdentityKeys() async { + await _e2ee.resetIdentityKeys(); } // Manual key upload for testing diff --git a/sojorn_app/lib/services/simple_e2ee_service.dart b/sojorn_app/lib/services/simple_e2ee_service.dart index 996c2fa..f73dc43 100644 --- a/sojorn_app/lib/services/simple_e2ee_service.dart +++ b/sojorn_app/lib/services/simple_e2ee_service.dart @@ -96,36 +96,18 @@ class SimpleE2EEService { } - // Force reset to fix 208-bit key bug - Future forceResetBrokenKeys() async { - - // Clear ALL storage completely + // Reset all local encryption keys and generate a fresh identity. + // Existing encrypted messages will become undecryptable after this. + Future resetIdentityKeys() async { await _storage.deleteAll(); - - // Clear local key variables _identityDhKeyPair = null; _identitySigningKeyPair = null; _signedPreKey = null; _oneTimePreKeys = null; _initializedForUserId = null; _initFuture = null; - - // Clear session cache _sessionCache.clear(); - - - // Generate fresh identity with proper key lengths 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 diff --git a/sojorn_app/lib/widgets/sojorn_rich_text.dart b/sojorn_app/lib/widgets/sojorn_rich_text.dart index f3d99f0..fe5bbaf 100644 --- a/sojorn_app/lib/widgets/sojorn_rich_text.dart +++ b/sojorn_app/lib/widgets/sojorn_rich_text.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; import '../utils/link_handler.dart'; +import '../routes/app_routes.dart'; import '../screens/discover/discover_screen.dart'; /// Rich text widget that automatically detects and styles URLs and mentions. @@ -107,11 +108,11 @@ class sojornRichText extends StatelessWidget { recognizer: TapGestureRecognizer() ..onTap = () { if (isMention) { - _navigateToProfile(matchText); + _navigateToProfile(context, matchText); } else if (isHashtag) { - _navigateToHashtag(matchText); + _navigateToHashtag(context, matchText); } else { - _navigateToUrl(matchText); + _navigateToUrl(context, matchText); } }, ), @@ -144,23 +145,22 @@ class sojornRichText extends StatelessWidget { } } - void _navigateToProfile(String username) { - // Remove @ prefix if present + void _navigateToProfile(BuildContext context, String username) { final cleanUsername = username.startsWith('@') ? username.substring(1) : username; - // Navigate to profile screen - // This would typically use GoRouter or Navigator - print('Navigate to profile: $cleanUsername'); + AppRoutes.navigateToProfile(context, cleanUsername); } - void _navigateToHashtag(String hashtag) { - // Remove # prefix if present + void _navigateToHashtag(BuildContext context, String hashtag) { final cleanHashtag = hashtag.startsWith('#') ? hashtag.substring(1) : hashtag; - // Navigate to search/discover with hashtag - print('Navigate to hashtag: $cleanHashtag'); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DiscoverScreen(initialQuery: '#$cleanHashtag'), + ), + ); } - void _navigateToUrl(String url) { - // Launch URL in browser or handle in-app - print('Navigate to URL: $url'); + void _navigateToUrl(BuildContext context, String url) { + LinkHandler.launchLink(context, url); } }