sojorn/go-backend/internal/handlers/repost_handler.go
Patrick Britton c3329a0893 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>
2026-02-17 14:04:24 -06:00

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, &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
}