- Add AI moderation to comments (was missing protection) - Enhance post moderation to analyze images, videos, thumbnails - Add FlagComment method for comment flagging - Extract media URLs for comprehensive content analysis - Update moderation config and models - Add OpenAI and Google Vision API integration - Fix database connection to use localhost This ensures all text, image, and video content is protected by AI moderation.
888 lines
25 KiB
Go
888 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/patbritton/sojorn-backend/internal/models"
|
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
|
"github.com/patbritton/sojorn-backend/internal/services"
|
|
"github.com/patbritton/sojorn-backend/pkg/utils"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type PostHandler struct {
|
|
postRepo *repository.PostRepository
|
|
userRepo *repository.UserRepository
|
|
feedService *services.FeedService
|
|
assetService *services.AssetService
|
|
notificationService *services.NotificationService
|
|
moderationService *services.ModerationService
|
|
}
|
|
|
|
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService) *PostHandler {
|
|
return &PostHandler{
|
|
postRepo: postRepo,
|
|
userRepo: userRepo,
|
|
feedService: feedService,
|
|
assetService: assetService,
|
|
notificationService: notificationService,
|
|
moderationService: moderationService,
|
|
}
|
|
}
|
|
|
|
func (h *PostHandler) CreateComment(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, _ := uuid.Parse(userIDStr.(string))
|
|
postID := c.Param("id")
|
|
|
|
var req struct {
|
|
Body string `json:"body" binding:"required,max=500"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
parentUUID, err := uuid.Parse(postID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid post ID"})
|
|
return
|
|
}
|
|
|
|
tags := utils.ExtractHashtags(req.Body)
|
|
tone := "neutral"
|
|
cis := 0.8
|
|
|
|
// AI Moderation Check for Comments
|
|
if h.moderationService != nil {
|
|
mediaURLs := []string{} // Comments don't have media in current implementation
|
|
scores, reason, err := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body, mediaURLs)
|
|
if err == nil {
|
|
cis = (scores.Hate + scores.Greed + scores.Delusion) / 3.0
|
|
cis = 1.0 - cis // Invert so 1.0 is pure, 0.0 is toxic
|
|
tone = reason
|
|
}
|
|
}
|
|
|
|
post := &models.Post{
|
|
AuthorID: userID,
|
|
Body: req.Body,
|
|
Status: "active",
|
|
ToneLabel: &tone,
|
|
CISScore: &cis,
|
|
BodyFormat: "plain",
|
|
Tags: tags,
|
|
IsBeacon: false,
|
|
IsActiveBeacon: false,
|
|
AllowChain: true,
|
|
Visibility: "public",
|
|
ChainParentID: &parentUUID,
|
|
}
|
|
|
|
// Check if comment should be flagged for moderation
|
|
if h.moderationService != nil && tone != "" && tone != "neutral" {
|
|
post.Status = "pending_moderation"
|
|
}
|
|
|
|
if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
comment := &models.Comment{
|
|
ID: post.ID,
|
|
PostID: postID,
|
|
AuthorID: post.AuthorID,
|
|
Body: post.Body,
|
|
Status: post.Status,
|
|
CreatedAt: post.CreatedAt,
|
|
}
|
|
|
|
// Flag comment if needed
|
|
if h.moderationService != nil && post.Status == "pending_moderation" {
|
|
mediaURLs := []string{}
|
|
scores, reason, _ := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body, mediaURLs)
|
|
_ = h.moderationService.FlagComment(c.Request.Context(), post.ID, scores, reason)
|
|
}
|
|
|
|
// Get post details for notification
|
|
rootPost, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
|
if err == nil && rootPost.AuthorID.String() != userIDStr.(string) {
|
|
// Get actor details
|
|
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
|
if err == nil && h.notificationService != nil {
|
|
// Determine post type for proper deep linking
|
|
postType := "standard"
|
|
if rootPost.IsBeacon {
|
|
postType = "beacon"
|
|
} else if rootPost.VideoURL != nil && *rootPost.VideoURL != "" {
|
|
postType = "quip"
|
|
}
|
|
|
|
commentIDStr := comment.ID.String()
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"post_id": postID,
|
|
"post_type": postType,
|
|
}
|
|
go h.notificationService.CreateNotification(
|
|
context.Background(),
|
|
rootPost.AuthorID.String(),
|
|
userIDStr.(string),
|
|
"comment",
|
|
&postID,
|
|
&commentIDStr,
|
|
metadata,
|
|
)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
|
}
|
|
|
|
func (h *PostHandler) GetNearbyBeacons(c *gin.Context) {
|
|
lat := utils.GetQueryFloat(c, "lat", 0)
|
|
long := utils.GetQueryFloat(c, "long", 0)
|
|
radius := utils.GetQueryInt(c, "radius", 16000)
|
|
|
|
beacons, err := h.postRepo.GetNearbyBeacons(c.Request.Context(), lat, long, radius)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch nearby beacons", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"beacons": beacons})
|
|
}
|
|
|
|
func (h *PostHandler) CreatePost(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, _ := uuid.Parse(userIDStr.(string))
|
|
|
|
var req struct {
|
|
CategoryID *string `json:"category_id"`
|
|
Body string `json:"body" binding:"required,max=500"`
|
|
ImageURL *string `json:"image_url"`
|
|
VideoURL *string `json:"video_url"`
|
|
Thumbnail *string `json:"thumbnail_url"`
|
|
DurationMS *int `json:"duration_ms"`
|
|
AllowChain *bool `json:"allow_chain"`
|
|
ChainParentID *string `json:"chain_parent_id"`
|
|
IsBeacon bool `json:"is_beacon"`
|
|
BeaconType *string `json:"beacon_type"`
|
|
BeaconLat *float64 `json:"beacon_lat"`
|
|
BeaconLong *float64 `json:"beacon_long"`
|
|
TTLHours *int `json:"ttl_hours"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 1. Check rate limit (Simplification)
|
|
trustState, err := h.userRepo.GetTrustState(c.Request.Context(), userID.String())
|
|
if err == nil && trustState.PostsToday >= 50 { // Example hard limit
|
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Daily post limit reached"})
|
|
return
|
|
}
|
|
|
|
// 2. Extract tags
|
|
tags := utils.ExtractHashtags(req.Body)
|
|
|
|
// 3. Mock Tone Check (In production, this would call a service or AI model)
|
|
tone := "neutral"
|
|
cis := 0.8
|
|
|
|
// 4. Resolve TTL
|
|
var expiresAt *time.Time
|
|
if req.TTLHours != nil && *req.TTLHours > 0 {
|
|
t := time.Now().Add(time.Duration(*req.TTLHours) * time.Hour)
|
|
expiresAt = &t
|
|
}
|
|
|
|
duration := 0
|
|
if req.DurationMS != nil {
|
|
duration = *req.DurationMS
|
|
}
|
|
|
|
allowChain := !req.IsBeacon
|
|
if req.AllowChain != nil {
|
|
allowChain = *req.AllowChain
|
|
}
|
|
|
|
if req.ChainParentID != nil && *req.ChainParentID != "" {
|
|
log.Info().
|
|
Str("chain_parent_id", *req.ChainParentID).
|
|
Bool("allow_chain", allowChain).
|
|
Msg("CreatePost with chain parent")
|
|
} else {
|
|
log.Info().
|
|
Bool("allow_chain", allowChain).
|
|
Msg("CreatePost without chain parent")
|
|
}
|
|
|
|
post := &models.Post{
|
|
AuthorID: userID,
|
|
Body: req.Body,
|
|
Status: "active",
|
|
ToneLabel: &tone,
|
|
CISScore: &cis,
|
|
ImageURL: req.ImageURL,
|
|
VideoURL: req.VideoURL,
|
|
ThumbnailURL: req.Thumbnail,
|
|
DurationMS: duration,
|
|
BodyFormat: "plain",
|
|
Tags: tags,
|
|
IsBeacon: req.IsBeacon,
|
|
BeaconType: req.BeaconType,
|
|
Confidence: 0.5, // Initial confidence
|
|
IsActiveBeacon: req.IsBeacon,
|
|
AllowChain: allowChain,
|
|
Visibility: "public",
|
|
ExpiresAt: expiresAt,
|
|
Lat: req.BeaconLat,
|
|
Long: req.BeaconLong,
|
|
}
|
|
|
|
if req.CategoryID != nil {
|
|
catID, _ := uuid.Parse(*req.CategoryID)
|
|
post.CategoryID = &catID
|
|
}
|
|
|
|
if req.ChainParentID != nil && *req.ChainParentID != "" {
|
|
parentID, err := uuid.Parse(*req.ChainParentID)
|
|
if err == nil {
|
|
post.ChainParentID = &parentID
|
|
}
|
|
}
|
|
|
|
// 5. AI Moderation Check - Comprehensive Content Analysis
|
|
if h.moderationService != nil {
|
|
// Extract all media URLs for analysis
|
|
mediaURLs := []string{}
|
|
if req.ImageURL != nil && *req.ImageURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.ImageURL)
|
|
}
|
|
if req.VideoURL != nil && *req.VideoURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.VideoURL)
|
|
}
|
|
if req.Thumbnail != nil && *req.Thumbnail != "" {
|
|
mediaURLs = append(mediaURLs, *req.Thumbnail)
|
|
}
|
|
|
|
scores, reason, err := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body, mediaURLs)
|
|
if err == nil {
|
|
cis = (scores.Hate + scores.Greed + scores.Delusion) / 3.0
|
|
cis = 1.0 - cis // Invert so 1.0 is pure, 0.0 is toxic
|
|
post.CISScore = &cis
|
|
post.ToneLabel = &reason
|
|
|
|
if reason != "" {
|
|
// Flag if any poison is detected in text or media
|
|
post.Status = "pending_moderation"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create post
|
|
err = h.postRepo.CreatePost(c.Request.Context(), post)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Handle Flags - Comprehensive Content Flagging
|
|
if h.moderationService != nil && post.Status == "pending_moderation" {
|
|
// Extract all media URLs for flagging
|
|
mediaURLs := []string{}
|
|
if req.ImageURL != nil && *req.ImageURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.ImageURL)
|
|
}
|
|
if req.VideoURL != nil && *req.VideoURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.VideoURL)
|
|
}
|
|
if req.Thumbnail != nil && *req.Thumbnail != "" {
|
|
mediaURLs = append(mediaURLs, *req.Thumbnail)
|
|
}
|
|
|
|
scores, reason, _ := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body, mediaURLs)
|
|
_ = h.moderationService.FlagPost(c.Request.Context(), post.ID, scores, reason)
|
|
}
|
|
|
|
// Check for @mentions and notify mentioned users
|
|
go func() {
|
|
if h.notificationService != nil && strings.Contains(req.Body, "@") {
|
|
postIDStr := post.ID.String()
|
|
h.notificationService.NotifyMention(c.Request.Context(), userIDStr.(string), postIDStr, req.Body)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"post": post,
|
|
"tags": tags,
|
|
"tone_analysis": gin.H{
|
|
"tone": tone,
|
|
"cis": cis,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (h *PostHandler) GetFeed(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
category := c.Query("category")
|
|
hasVideo := c.Query("has_video") == "true"
|
|
|
|
posts, err := h.feedService.GetFeed(c.Request.Context(), userIDStr.(string), category, hasVideo, limit, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feed", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetProfilePosts(c *gin.Context) {
|
|
authorID := c.Param("id")
|
|
if authorID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Author ID required"})
|
|
return
|
|
}
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
onlyChains := c.Query("chained") == "true"
|
|
|
|
viewerID := ""
|
|
if val, exists := c.Get("user_id"); exists {
|
|
viewerID = val.(string)
|
|
}
|
|
|
|
posts, err := h.postRepo.GetPostsByAuthor(c.Request.Context(), authorID, viewerID, limit, offset, onlyChains)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile posts", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetPost(c *gin.Context) {
|
|
log.Error().Msg("=== DEBUG: GetPost handler called ===")
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"})
|
|
return
|
|
}
|
|
|
|
// Sign URL
|
|
if post.ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ImageURL)
|
|
post.ImageURL = &signed
|
|
}
|
|
if post.VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
|
post.VideoURL = &signed
|
|
}
|
|
if post.ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ThumbnailURL)
|
|
post.ThumbnailURL = &signed
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"post": post})
|
|
}
|
|
|
|
func (h *PostHandler) UpdatePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Body string `json:"body" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := h.postRepo.UpdatePost(c.Request.Context(), postID, userIDStr.(string), req.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post updated"})
|
|
}
|
|
|
|
func (h *PostHandler) DeletePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.DeletePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
|
|
}
|
|
|
|
func (h *PostHandler) PinPost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Pinned bool `json:"pinned"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := h.postRepo.PinPost(c.Request.Context(), postID, userIDStr.(string), req.Pinned)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to pin/unpin post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post pin status updated"})
|
|
}
|
|
|
|
func (h *PostHandler) UpdateVisibility(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Visibility string `json:"visibility" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := h.postRepo.UpdateVisibility(c.Request.Context(), postID, userIDStr.(string), req.Visibility)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update visibility", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post visibility updated"})
|
|
}
|
|
|
|
func (h *PostHandler) LikePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.LikePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to like post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Send push notification to post author
|
|
go func() {
|
|
// Use Background context because the request context will be cancelled
|
|
bgCtx := context.Background()
|
|
post, err := h.postRepo.GetPostByID(bgCtx, postID, userIDStr.(string))
|
|
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
|
return // Don't notify self
|
|
}
|
|
|
|
if h.notificationService != nil {
|
|
postType := "standard"
|
|
if post.IsBeacon {
|
|
postType = "beacon"
|
|
} else if post.VideoURL != nil && *post.VideoURL != "" {
|
|
postType = "quip"
|
|
}
|
|
|
|
h.notificationService.NotifyLike(
|
|
bgCtx,
|
|
post.AuthorID.String(),
|
|
userIDStr.(string),
|
|
postID,
|
|
postType,
|
|
"❤️",
|
|
)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post liked"})
|
|
}
|
|
|
|
func (h *PostHandler) UnlikePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.UnlikePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unlike post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
|
|
}
|
|
|
|
func (h *PostHandler) SavePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.SavePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Send push notification to post author
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
post, err := h.postRepo.GetPostByID(bgCtx, postID, userIDStr.(string))
|
|
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
|
return // Don't notify self
|
|
}
|
|
|
|
actor, err := h.userRepo.GetProfileByID(bgCtx, userIDStr.(string))
|
|
if err != nil || h.notificationService == nil {
|
|
return
|
|
}
|
|
|
|
// Determine post type for proper deep linking
|
|
postType := "standard"
|
|
if post.IsBeacon {
|
|
postType = "beacon"
|
|
} else if post.VideoURL != nil && *post.VideoURL != "" {
|
|
postType = "quip"
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"post_id": postID,
|
|
"post_type": postType,
|
|
}
|
|
h.notificationService.CreateNotification(
|
|
context.Background(),
|
|
post.AuthorID.String(),
|
|
userIDStr.(string),
|
|
"save",
|
|
&postID,
|
|
nil,
|
|
metadata,
|
|
)
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post saved"})
|
|
}
|
|
|
|
func (h *PostHandler) UnsavePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.UnsavePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsave post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post unsaved"})
|
|
}
|
|
|
|
func (h *PostHandler) GetSavedPosts(c *gin.Context) {
|
|
userID := c.Param("id")
|
|
if userID == "" || userID == "me" {
|
|
userIDStr, exists := c.Get("user_id")
|
|
if exists {
|
|
userID = userIDStr.(string)
|
|
}
|
|
}
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
return
|
|
}
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
|
|
posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), userID, limit, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved posts", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetLikedPosts(c *gin.Context) {
|
|
userID := c.Param("id")
|
|
if userID == "" || userID == "me" {
|
|
userIDStr, exists := c.Get("user_id")
|
|
if exists {
|
|
userID = userIDStr.(string)
|
|
}
|
|
}
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
return
|
|
}
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
|
|
posts, err := h.postRepo.GetLikedPosts(c.Request.Context(), userID, limit, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch liked posts", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetPostChain(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
|
|
posts, err := h.postRepo.GetPostChain(c.Request.Context(), postID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch post chain", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Sign URLs for all posts in the chain
|
|
for i := range posts {
|
|
if posts[i].ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ImageURL)
|
|
posts[i].ImageURL = &signed
|
|
}
|
|
if posts[i].VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*posts[i].VideoURL)
|
|
posts[i].VideoURL = &signed
|
|
}
|
|
if posts[i].ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ThumbnailURL)
|
|
posts[i].ThumbnailURL = &signed
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
focusContext, err := h.postRepo.GetPostFocusContext(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch focus context", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.signPostMedia(focusContext.TargetPost)
|
|
h.signPostMedia(focusContext.ParentPost)
|
|
for i := range focusContext.Children {
|
|
h.signPostMedia(&focusContext.Children[i])
|
|
}
|
|
|
|
c.JSON(http.StatusOK, focusContext)
|
|
}
|
|
|
|
func (h *PostHandler) signPostMedia(post *models.Post) {
|
|
if post == nil {
|
|
return
|
|
}
|
|
if post.ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ImageURL)
|
|
post.ImageURL = &signed
|
|
}
|
|
if post.VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
|
post.VideoURL = &signed
|
|
}
|
|
if post.ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ThumbnailURL)
|
|
post.ThumbnailURL = &signed
|
|
}
|
|
}
|
|
|
|
func (h *PostHandler) VouchBeacon(c *gin.Context) {
|
|
beaconID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.VouchBeacon(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err != nil {
|
|
if err.Error() == "post is not a beacon" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to vouch for beacon", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get beacon details for notification
|
|
beacon, err := h.postRepo.GetPostByID(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err == nil && beacon.AuthorID.String() != userIDStr.(string) {
|
|
// Get actor details
|
|
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
|
if err == nil && h.notificationService != nil {
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"beacon_id": beaconID,
|
|
}
|
|
h.notificationService.CreateNotification(
|
|
c.Request.Context(),
|
|
beacon.AuthorID.String(),
|
|
userIDStr.(string),
|
|
"beacon_vouch",
|
|
&beaconID,
|
|
nil,
|
|
metadata,
|
|
)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Beacon vouched successfully"})
|
|
}
|
|
|
|
func (h *PostHandler) ReportBeacon(c *gin.Context) {
|
|
beaconID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.ReportBeacon(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err != nil {
|
|
if err.Error() == "post is not a beacon" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to report beacon", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get beacon details for notification
|
|
beacon, err := h.postRepo.GetPostByID(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err == nil && beacon.AuthorID.String() != userIDStr.(string) {
|
|
// Get actor details
|
|
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
|
if err == nil && h.notificationService != nil {
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"beacon_id": beaconID,
|
|
}
|
|
h.notificationService.CreateNotification(
|
|
c.Request.Context(),
|
|
beacon.AuthorID.String(),
|
|
userIDStr.(string),
|
|
"beacon_report",
|
|
&beaconID,
|
|
nil,
|
|
metadata,
|
|
)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Beacon reported successfully"})
|
|
}
|
|
|
|
func (h *PostHandler) RemoveBeaconVote(c *gin.Context) {
|
|
beaconID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.RemoveBeaconVote(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove beacon vote", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Beacon vote removed successfully"})
|
|
}
|
|
|
|
func (h *PostHandler) ToggleReaction(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Emoji string `json:"emoji" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
emoji := strings.TrimSpace(req.Emoji)
|
|
if emoji == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"})
|
|
return
|
|
}
|
|
|
|
counts, myReactions, err := h.postRepo.ToggleReaction(c.Request.Context(), postID, userIDStr.(string), emoji)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle reaction", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check if reaction was added (exists in myReactions)
|
|
reactionAdded := false
|
|
for _, r := range myReactions {
|
|
if r == emoji {
|
|
reactionAdded = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if reactionAdded {
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
post, err := h.postRepo.GetPostByID(bgCtx, postID, userIDStr.(string))
|
|
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
|
return // Don't notify self
|
|
}
|
|
|
|
if h.notificationService != nil {
|
|
// Get actor details
|
|
actor, err := h.userRepo.GetProfileByID(bgCtx, userIDStr.(string))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"post_id": postID,
|
|
"emoji": emoji,
|
|
}
|
|
|
|
// Using "like" type for now, or "quip_reaction" if quip
|
|
notifType := "like"
|
|
if post.VideoURL != nil && *post.VideoURL != "" {
|
|
notifType = "quip_reaction"
|
|
metadata["post_type"] = "quip"
|
|
} else {
|
|
metadata["post_type"] = "post"
|
|
}
|
|
|
|
h.notificationService.CreateNotification(
|
|
bgCtx,
|
|
post.AuthorID.String(),
|
|
userIDStr.(string),
|
|
notifType,
|
|
&postID,
|
|
nil,
|
|
metadata,
|
|
)
|
|
}
|
|
}()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"reactions": counts,
|
|
"my_reactions": myReactions,
|
|
})
|
|
}
|