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>
503 lines
14 KiB
Go
503 lines
14 KiB
Go
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
|
|
}
|