package handlers import ( "context" "fmt" "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 contentFilter *services.ContentFilter openRouterService *services.OpenRouterService linkPreviewService *services.LinkPreviewService } func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService) *PostHandler { return &PostHandler{ postRepo: postRepo, userRepo: userRepo, feedService: feedService, assetService: assetService, notificationService: notificationService, moderationService: moderationService, contentFilter: contentFilter, openRouterService: openRouterService, linkPreviewService: linkPreviewService, } } // enrichLinkPreviews populates link_preview fields on a slice of posts via batch query. func (h *PostHandler) enrichLinkPreviews(ctx context.Context, posts []models.Post) { if h.linkPreviewService == nil || len(posts) == 0 { return } ids := make([]string, len(posts)) for i, p := range posts { ids[i] = p.ID.String() } previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, ids) if err != nil || len(previews) == 0 { return } for i := range posts { if lp, ok := previews[posts[i].ID.String()]; ok { posts[i].LinkPreviewURL = &lp.URL posts[i].LinkPreviewTitle = &lp.Title posts[i].LinkPreviewDescription = &lp.Description posts[i].LinkPreviewImageURL = &lp.ImageURL posts[i].LinkPreviewSiteName = &lp.SiteName } } } // enrichSinglePostLinkPreview populates link_preview fields on a single post. func (h *PostHandler) enrichSinglePostLinkPreview(ctx context.Context, post *models.Post) { if h.linkPreviewService == nil || post == nil { return } previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, []string{post.ID.String()}) if err != nil || len(previews) == 0 { return } if lp, ok := previews[post.ID.String()]; ok { post.LinkPreviewURL = &lp.URL post.LinkPreviewTitle = &lp.Title post.LinkPreviewDescription = &lp.Description post.LinkPreviewImageURL = &lp.ImageURL post.LinkPreviewSiteName = &lp.SiteName } } 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 } // Layer 0: Hard blocklist check — reject immediately, never save if h.contentFilter != nil { result := h.contentFilter.CheckContent(req.Body) if result.Blocked { strikeCount, consequence, _ := h.contentFilter.RecordStrikeWithIP(c.Request.Context(), userID, result.Category, req.Body, c.ClientIP()) c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": result.Message, "blocked": true, "category": result.Category, "strikes": strikeCount, "consequence": consequence, }) 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) } // Log AI moderation decision for comment if h.moderationService != nil { decision := "pass" if post.Status == "pending_moderation" { decision = "flag" } invCis := 1.0 - cis scores := &services.ThreePoisonsScore{Hate: invCis, Greed: 0, Delusion: 0} h.moderationService.LogAIDecision(c.Request.Context(), "comment", post.ID, userID, req.Body, scores, nil, decision, tone, "", nil) } // 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"` IsNSFW bool `json:"is_nsfw"` NSFWReason string `json:"nsfw_reason"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Layer 0: Hard blocklist check — reject immediately, never save if h.contentFilter != nil { result := h.contentFilter.CheckContent(req.Body) if result.Blocked { strikeCount, consequence, _ := h.contentFilter.RecordStrikeWithIP(c.Request.Context(), userID, result.Category, req.Body, c.ClientIP()) c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": result.Message, "blocked": true, "category": result.Category, "strikes": strikeCount, "consequence": consequence, }) 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, IsNSFW: req.IsNSFW, NSFWReason: req.NSFWReason, 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" } } } // 5b. OpenRouter AI Moderation — NSFW vs Flag decision (text + image + video) userSelfLabeledNSFW := req.IsNSFW orDecision := "" if h.openRouterService != nil { // Collect all moderation results to pick the worst outcome var allResults []*services.ModerationResult // Text moderation if req.Body != "" { textResult, textErr := h.openRouterService.ModerateText(c.Request.Context(), req.Body) if textErr == nil && textResult != nil { allResults = append(allResults, textResult) log.Info().Str("action", textResult.Action).Msg("Text moderation result") } } // Image moderation (vision model) if req.ImageURL != nil && *req.ImageURL != "" { imgResult, imgErr := h.openRouterService.ModerateImage(c.Request.Context(), *req.ImageURL) if imgErr == nil && imgResult != nil { allResults = append(allResults, imgResult) log.Info().Str("action", imgResult.Action).Str("url", *req.ImageURL).Msg("Image moderation result") } else if imgErr != nil { log.Warn().Err(imgErr).Msg("Image moderation failed — continuing with text result only") } } // Video moderation via thumbnail (vision model on representative frame) if req.VideoURL != nil && *req.VideoURL != "" { thumbnailURL := "" if req.Thumbnail != nil && *req.Thumbnail != "" { thumbnailURL = *req.Thumbnail } if thumbnailURL != "" { vidResult, vidErr := h.openRouterService.ModerateImage(c.Request.Context(), thumbnailURL) if vidErr == nil && vidResult != nil { allResults = append(allResults, vidResult) log.Info().Str("action", vidResult.Action).Str("thumbnail", thumbnailURL).Msg("Video thumbnail moderation result") } else if vidErr != nil { log.Warn().Err(vidErr).Msg("Video thumbnail moderation failed — continuing without") } } } // Merge results: worst outcome wins (flag > nsfw > clean) worstAction := "clean" var worstResult *services.ModerationResult for _, r := range allResults { if r.Action == "flag" { worstAction = "flag" worstResult = r break // Can't get worse than flag } else if r.Action == "nsfw" && worstAction != "flag" { worstAction = "nsfw" worstResult = r } else if worstResult == nil { worstResult = r } } if worstResult != nil { orDecision = worstAction switch worstAction { case "nsfw": post.IsNSFW = true if worstResult.NSFWReason != "" { post.NSFWReason = worstResult.NSFWReason } if post.Status != "pending_moderation" { post.Status = "active" // NSFW posts are active but blurred } case "flag": // NOT ALLOWED — will be removed after creation post.Status = "removed" } // Update CIS from worst result scores if available if worstResult.Hate > 0 || worstResult.Greed > 0 || worstResult.Delusion > 0 { orCis := 1.0 - (worstResult.Hate+worstResult.Greed+worstResult.Delusion)/3.0 post.CISScore = &orCis } } } // 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" || post.Status == "removed") { 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) } // NSFW auto-reclassify: AI says NSFW but user didn't self-label → send warning if post.IsNSFW && !userSelfLabeledNSFW && h.notificationService != nil { go func() { ctx := context.Background() h.notificationService.NotifyNSFWWarning(ctx, userID.String(), post.ID.String()) log.Info().Str("post_id", post.ID.String()).Str("author_id", userID.String()).Msg("NSFW warning sent — post auto-labeled") }() } // NOT ALLOWED: AI flagged → post removed, create violation, send appeal notification + email if post.Status == "removed" && orDecision == "flag" { go func() { ctx := context.Background() // Send in-app notification if h.notificationService != nil { h.notificationService.NotifyContentRemoved(ctx, userID.String(), post.ID.String()) } // Create moderation violation record if h.moderationService != nil { h.moderationService.FlagPost(ctx, post.ID, &services.ThreePoisonsScore{Hate: 1.0}, "not_allowed") } // Send appeal email — get email from users table, display name from profiles var userEmail string h.postRepo.Pool().QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&userEmail) profile, _ := h.userRepo.GetProfileByID(ctx, userID.String()) if userEmail != "" { displayName := "there" if profile != nil && profile.DisplayName != nil { displayName = *profile.DisplayName } snippet := req.Body if len(snippet) > 100 { snippet = snippet[:100] + "..." } appealBody := fmt.Sprintf( "Hi %s,\n\n"+ "Your recent post on Sojorn was removed because it was found to violate our community guidelines.\n\n"+ "Post content: \"%s\"\n\n"+ "If you believe this was a mistake, you can appeal this decision in your Sojorn app:\n"+ "Go to Profile → Settings → Appeals\n\n"+ "Our moderation team will review your appeal within 48 hours.\n\n"+ "— The Sojorn Team", displayName, snippet, ) log.Info().Str("email", userEmail).Msg("Sending content removal appeal email") h.postRepo.Pool().Exec(ctx, `INSERT INTO email_queue (to_email, subject, body, created_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT DO NOTHING`, userEmail, "Your Sojorn post was removed", appealBody, ) } log.Warn().Str("post_id", post.ID.String()).Str("author_id", userID.String()).Msg("Post removed by AI moderation — not allowed content") }() } // Log AI moderation decision to audit log if h.moderationService != nil { decision := "pass" flagReason := "" if post.ToneLabel != nil && *post.ToneLabel != "" { flagReason = *post.ToneLabel } if post.Status == "removed" { decision = "flag" } else if post.Status == "pending_moderation" { decision = "flag" } else if post.IsNSFW { decision = "nsfw" } var scores *services.ThreePoisonsScore if post.CISScore != nil { invCis := 1.0 - *post.CISScore scores = &services.ThreePoisonsScore{Hate: invCis, Greed: 0, Delusion: 0} } else { scores = &services.ThreePoisonsScore{} } h.moderationService.LogAIDecision(c.Request.Context(), "post", post.ID, userID, req.Body, scores, nil, decision, flagReason, orDecision, nil) } // Auto-extract link preview from post body (async — don't block response) if h.linkPreviewService != nil { go func() { ctx := context.Background() linkURL := services.ExtractFirstURL(req.Body) if linkURL != "" { // Check if author is an official account (trusted = skip safety checks) var isOfficial bool _ = h.postRepo.Pool().QueryRow(ctx, `SELECT COALESCE(is_official, false) FROM profiles WHERE id = $1`, userID).Scan(&isOfficial) lp, err := h.linkPreviewService.FetchPreview(ctx, linkURL, isOfficial) if err == nil && lp != nil { _ = h.linkPreviewService.SaveLinkPreview(ctx, post.ID.String(), lp) } } }() } // 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" // Check user's NSFW preference showNSFW := false if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), userIDStr.(string)); err == nil && settings.NSFWEnabled != nil { showNSFW = *settings.NSFWEnabled } posts, err := h.feedService.GetFeed(c.Request.Context(), userIDStr.(string), category, hasVideo, limit, offset, showNSFW) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feed", "details": err.Error()}) return } h.enrichLinkPreviews(c.Request.Context(), posts) 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) } // Check viewer's NSFW preference showNSFW := false if viewerID != "" { if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID); err == nil && settings.NSFWEnabled != nil { showNSFW = *settings.NSFWEnabled } } posts, err := h.postRepo.GetPostsByAuthor(c.Request.Context(), authorID, viewerID, limit, offset, onlyChains, showNSFW) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile posts", "details": err.Error()}) return } h.enrichLinkPreviews(c.Request.Context(), posts) 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") // Check viewer's NSFW preference showNSFW := false if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), userIDStr.(string)); err == nil && settings.NSFWEnabled != nil { showNSFW = *settings.NSFWEnabled } post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string), showNSFW) 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 } h.enrichSinglePostLinkPreview(c.Request.Context(), post) 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) // Check viewer's NSFW preference showNSFW := false if viewerID, exists := c.Get("user_id"); exists { if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID.(string)); err == nil && settings.NSFWEnabled != nil { showNSFW = *settings.NSFWEnabled } } posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), userID, limit, offset, showNSFW) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved posts", "details": err.Error()}) return } h.enrichLinkPreviews(c.Request.Context(), posts) 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) // Check viewer's NSFW preference showNSFW := false if viewerID, exists := c.Get("user_id"); exists { if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID.(string)); err == nil && settings.NSFWEnabled != nil { showNSFW = *settings.NSFWEnabled } } posts, err := h.postRepo.GetLikedPosts(c.Request.Context(), userID, limit, offset, showNSFW) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch liked posts", "details": err.Error()}) return } h.enrichLinkPreviews(c.Request.Context(), posts) c.JSON(http.StatusOK, gin.H{"posts": posts}) } func (h *PostHandler) GetPostChain(c *gin.Context) { postID := c.Param("id") // Check viewer's NSFW preference showNSFW := false if viewerID, exists := c.Get("user_id"); exists { if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID.(string)); err == nil && settings.NSFWEnabled != nil { showNSFW = *settings.NSFWEnabled } } posts, err := h.postRepo.GetPostChain(c.Request.Context(), postID, showNSFW) 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 } } h.enrichLinkPreviews(c.Request.Context(), posts) c.JSON(http.StatusOK, gin.H{"posts": posts}) } func (h *PostHandler) GetPostFocusContext(c *gin.Context) { postID := c.Param("id") userIDStr, _ := c.Get("user_id") // Check viewer's NSFW preference showNSFW := false if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), userIDStr.(string)); err == nil && settings.NSFWEnabled != nil { showNSFW = *settings.NSFWEnabled } focusContext, err := h.postRepo.GetPostFocusContext(c.Request.Context(), postID, userIDStr.(string), showNSFW) 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]) } // Enrich link previews for all posts in focus context h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.TargetPost) h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.ParentPost) for i := range focusContext.Children { h.enrichSinglePostLinkPreview(c.Request.Context(), &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 } // Beacons are anonymous — no notifications sent to preserve privacy 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 } // Beacons are anonymous — no notifications sent to preserve privacy 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, }) }