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