package handlers import ( "context" "encoding/json" "log" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services" ) type BoardHandler struct { pool *pgxpool.Pool contentFilter *services.ContentFilter moderationService *services.ModerationService } func NewBoardHandler(pool *pgxpool.Pool, opts ...interface{}) *BoardHandler { h := &BoardHandler{pool: pool} for _, opt := range opts { switch v := opt.(type) { case *services.ContentFilter: h.contentFilter = v case *services.ModerationService: h.moderationService = v } } return h } // ── List nearby board entries ───────────────────────────────────────────── func (h *BoardHandler) ListNearby(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) latStr := c.Query("lat") longStr := c.Query("long") radiusStr := c.DefaultQuery("radius", "5000") topic := c.Query("topic") sort := c.DefaultQuery("sort", "new") lat, err := strconv.ParseFloat(latStr, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "lat required"}) return } long, err := strconv.ParseFloat(longStr, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "long required"}) return } radius, _ := strconv.Atoi(radiusStr) if radius <= 0 || radius > 50000 { radius = 5000 } query := ` SELECT e.id, e.body, COALESCE(e.image_url, ''), e.topic, e.lat, e.long, e.upvotes, e.reply_count, e.is_pinned, e.created_at, pr.handle, pr.display_name, COALESCE(pr.avatar_url, ''), EXISTS(SELECT 1 FROM board_votes bv WHERE bv.user_id = $4 AND bv.entry_id = e.id) AS has_voted FROM board_entries e JOIN profiles pr ON e.author_id = pr.id WHERE e.is_active = TRUE AND ST_DWithin(e.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3) ` args := []any{lat, long, radius, userID} argIdx := 5 if topic != "" { query += ` AND e.topic = $` + strconv.Itoa(argIdx) args = append(args, topic) argIdx++ } orderClause := "e.is_pinned DESC, e.created_at DESC" switch sort { case "top": orderClause = "e.is_pinned DESC, e.upvotes DESC, e.reply_count DESC, e.created_at DESC" case "hot": orderClause = "e.is_pinned DESC, (e.upvotes * 2 + e.reply_count) DESC, e.created_at DESC" } query += ` ORDER BY ` + orderClause + ` LIMIT 100` rows, err := h.pool.Query(c.Request.Context(), query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch board entries"}) return } defer rows.Close() var entries []gin.H for rows.Next() { var id uuid.UUID var body, imageURL, tp, handle, displayName, avatarURL string var eLat, eLong float64 var upvotes, replyCount int var isPinned, hasVoted bool var createdAt time.Time if err := rows.Scan(&id, &body, &imageURL, &tp, &eLat, &eLong, &upvotes, &replyCount, &isPinned, &createdAt, &handle, &displayName, &avatarURL, &hasVoted); err != nil { continue } entries = append(entries, gin.H{ "id": id, "body": body, "image_url": imageURL, "topic": tp, "lat": eLat, "long": eLong, "upvotes": upvotes, "reply_count": replyCount, "is_pinned": isPinned, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, "has_voted": hasVoted, }) } if entries == nil { entries = []gin.H{} } // Check if the current user is a neighborhood admin for any nearby group isAdmin := h.isNeighborhoodAdmin(c, userID, lat, long) c.JSON(http.StatusOK, gin.H{"entries": entries, "is_neighborhood_admin": isAdmin}) } // ── Create board entry ──────────────────────────────────────────────────── func (h *BoardHandler) CreateEntry(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } var req struct { Body string `json:"body" binding:"required,max=1000"` ImageURL *string `json:"image_url"` Topic string `json:"topic"` Lat float64 `json:"lat" binding:"required"` Long float64 `json:"long" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Topic == "" { req.Topic = "community" } // Layer 0: Hard blocklist check (instant rejection) if h.contentFilter != nil { result := h.contentFilter.CheckContent(req.Body) if result.Blocked { go h.contentFilter.RecordStrike(c.Request.Context(), userID, result.Category, req.Body) c.JSON(http.StatusForbidden, gin.H{ "error": "Content violates community guidelines", "category": result.Category, }) return } } var id uuid.UUID var createdAt time.Time err = h.pool.QueryRow(c.Request.Context(), ` INSERT INTO board_entries (author_id, body, image_url, topic, lat, long) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at `, userID, req.Body, req.ImageURL, req.Topic, req.Lat, req.Long).Scan(&id, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create entry"}) return } // Layer 1: AI moderation (async — doesn't block the response) go h.aiModerateEntry(id, req.Body, userID) // Fetch author info for response var handle, displayName, avatarURL string _ = h.pool.QueryRow(c.Request.Context(), `SELECT handle, display_name, COALESCE(avatar_url, '') FROM profiles WHERE id = $1`, userID, ).Scan(&handle, &displayName, &avatarURL) c.JSON(http.StatusCreated, gin.H{"entry": gin.H{ "id": id, "body": req.Body, "image_url": req.ImageURL, "topic": req.Topic, "lat": req.Lat, "long": req.Long, "upvotes": 0, "reply_count": 0, "is_pinned": false, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, "has_voted": false, }}) } // ── Get single entry with replies ───────────────────────────────────────── func (h *BoardHandler) GetEntry(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) entryID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entry id"}) return } // Fetch entry var body, imageURL, topic, handle, displayName, avatarURL string var eLat, eLong float64 var upvotes, replyCount int var isPinned, hasVoted bool var createdAt time.Time err = h.pool.QueryRow(c.Request.Context(), ` SELECT e.body, COALESCE(e.image_url, ''), e.topic, e.lat, e.long, e.upvotes, e.reply_count, e.is_pinned, e.created_at, pr.handle, pr.display_name, COALESCE(pr.avatar_url, ''), EXISTS(SELECT 1 FROM board_votes bv WHERE bv.user_id = $2 AND bv.entry_id = e.id) AS has_voted FROM board_entries e JOIN profiles pr ON e.author_id = pr.id WHERE e.id = $1 AND e.is_active = TRUE `, entryID, userID).Scan(&body, &imageURL, &topic, &eLat, &eLong, &upvotes, &replyCount, &isPinned, &createdAt, &handle, &displayName, &avatarURL, &hasVoted) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "entry not found"}) return } // Fetch replies replyRows, err := h.pool.Query(c.Request.Context(), ` SELECT r.id, r.body, r.upvotes, r.created_at, pr.handle, pr.display_name, COALESCE(pr.avatar_url, ''), EXISTS(SELECT 1 FROM board_votes bv WHERE bv.user_id = $2 AND bv.reply_id = r.id) AS has_voted FROM board_replies r JOIN profiles pr ON r.author_id = pr.id WHERE r.entry_id = $1 AND r.is_active = TRUE ORDER BY r.created_at ASC `, entryID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch replies"}) return } defer replyRows.Close() var replies []gin.H for replyRows.Next() { var rID uuid.UUID var rBody, rHandle, rDisplayName, rAvatarURL string var rUpvotes int var rCreatedAt time.Time var rHasVoted bool if err := replyRows.Scan(&rID, &rBody, &rUpvotes, &rCreatedAt, &rHandle, &rDisplayName, &rAvatarURL, &rHasVoted); err != nil { continue } replies = append(replies, gin.H{ "id": rID, "body": rBody, "upvotes": rUpvotes, "created_at": rCreatedAt, "author_handle": rHandle, "author_display_name": rDisplayName, "author_avatar_url": rAvatarURL, "has_voted": rHasVoted, }) } if replies == nil { replies = []gin.H{} } // Check admin status for this entry isAdmin := h.isNeighborhoodAdmin(c, userID, eLat, eLong) c.JSON(http.StatusOK, gin.H{ "entry": gin.H{ "id": entryID, "body": body, "image_url": imageURL, "topic": topic, "lat": eLat, "long": eLong, "upvotes": upvotes, "reply_count": replyCount, "is_pinned": isPinned, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, "has_voted": hasVoted, }, "replies": replies, "is_neighborhood_admin": isAdmin, }) } // ── Reply to entry ──────────────────────────────────────────────────────── func (h *BoardHandler) CreateReply(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } entryID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entry id"}) return } 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 } // Layer 0: Hard blocklist check if h.contentFilter != nil { result := h.contentFilter.CheckContent(req.Body) if result.Blocked { go h.contentFilter.RecordStrike(c.Request.Context(), userID, result.Category, req.Body) c.JSON(http.StatusForbidden, gin.H{ "error": "Content violates community guidelines", "category": result.Category, }) return } } var replyID uuid.UUID var createdAt time.Time err = h.pool.QueryRow(c.Request.Context(), ` INSERT INTO board_replies (entry_id, author_id, body) VALUES ($1, $2, $3) RETURNING id, created_at `, entryID, userID, req.Body).Scan(&replyID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reply"}) return } // Bump reply count _, _ = h.pool.Exec(c.Request.Context(), `UPDATE board_entries SET reply_count = reply_count + 1, updated_at = NOW() WHERE id = $1`, entryID) // Layer 1: AI moderation (async) go h.aiModerateReply(replyID, req.Body, userID) var handle, displayName, avatarURL string _ = h.pool.QueryRow(c.Request.Context(), `SELECT handle, display_name, COALESCE(avatar_url, '') FROM profiles WHERE id = $1`, userID, ).Scan(&handle, &displayName, &avatarURL) c.JSON(http.StatusCreated, gin.H{"reply": gin.H{ "id": replyID, "body": req.Body, "upvotes": 0, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, "has_voted": false, }}) } // ── Upvote entry or reply (toggle) ──────────────────────────────────────── func (h *BoardHandler) ToggleVote(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } var req struct { EntryID *string `json:"entry_id"` ReplyID *string `json:"reply_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() if req.EntryID != nil && *req.EntryID != "" { entryID, err := uuid.Parse(*req.EntryID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entry_id"}) return } // Try delete (un-vote); if nothing deleted, insert (vote) tag, err := h.pool.Exec(ctx, `DELETE FROM board_votes WHERE user_id = $1 AND entry_id = $2`, userID, entryID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "vote failed"}) return } if tag.RowsAffected() > 0 { _, _ = h.pool.Exec(ctx, `UPDATE board_entries SET upvotes = GREATEST(upvotes - 1, 0) WHERE id = $1`, entryID) c.JSON(http.StatusOK, gin.H{"voted": false}) return } _, err = h.pool.Exec(ctx, `INSERT INTO board_votes (user_id, entry_id) VALUES ($1, $2)`, userID, entryID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "vote failed"}) return } _, _ = h.pool.Exec(ctx, `UPDATE board_entries SET upvotes = upvotes + 1 WHERE id = $1`, entryID) c.JSON(http.StatusOK, gin.H{"voted": true}) return } if req.ReplyID != nil && *req.ReplyID != "" { replyID, err := uuid.Parse(*req.ReplyID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reply_id"}) return } tag, err := h.pool.Exec(ctx, `DELETE FROM board_votes WHERE user_id = $1 AND reply_id = $2`, userID, replyID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "vote failed"}) return } if tag.RowsAffected() > 0 { _, _ = h.pool.Exec(ctx, `UPDATE board_replies SET upvotes = GREATEST(upvotes - 1, 0) WHERE id = $1`, replyID) c.JSON(http.StatusOK, gin.H{"voted": false}) return } _, err = h.pool.Exec(ctx, `INSERT INTO board_votes (user_id, reply_id) VALUES ($1, $2)`, userID, replyID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "vote failed"}) return } _, _ = h.pool.Exec(ctx, `UPDATE board_replies SET upvotes = upvotes + 1 WHERE id = $1`, replyID) c.JSON(http.StatusOK, gin.H{"voted": true}) return } c.JSON(http.StatusBadRequest, gin.H{"error": "entry_id or reply_id required"}) } // ── Admin: Remove board entry ───────────────────────────────────────────── // Neighborhood admins can remove entries in their neighborhood. // POST /board/:id/remove func (h *BoardHandler) RemoveEntry(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } entryID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entry id"}) return } var req struct { Reason string `json:"reason" binding:"required,min=5,max=500"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() // Check if the user is a neighborhood admin for the entry's location var entryLat, entryLong float64 err = h.pool.QueryRow(ctx, `SELECT lat, long FROM board_entries WHERE id = $1 AND is_active = TRUE`, entryID, ).Scan(&entryLat, &entryLong) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "entry not found"}) return } // Verify caller is neighborhood admin or app admin if !h.isNeighborhoodAdmin(c, userID, entryLat, entryLong) { c.JSON(http.StatusForbidden, gin.H{"error": "only neighborhood admins can remove content"}) return } // Soft-remove the entry _, err = h.pool.Exec(ctx, ` UPDATE board_entries SET is_active = FALSE, removed_by = $1, removed_reason = $2, removed_at = NOW(), updated_at = NOW() WHERE id = $3 `, userID, req.Reason, entryID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove entry"}) return } // Audit trail _, _ = h.pool.Exec(ctx, ` INSERT INTO board_moderation_actions (entry_id, moderator_id, action, reason) VALUES ($1, $2, 'remove', $3) `, entryID, userID, req.Reason) c.JSON(http.StatusOK, gin.H{"message": "entry removed", "entry_id": entryID}) } // ── Flag board content ──────────────────────────────────────────────────── // Any user can flag board content for review. // POST /board/:id/flag func (h *BoardHandler) FlagEntry(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } entryID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entry id"}) return } var req struct { Reason string `json:"reason" binding:"required,min=3,max=500"` ReplyID *string `json:"reply_id"` // optional: flag a specific reply } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() if req.ReplyID != nil && *req.ReplyID != "" { replyUUID, err := uuid.Parse(*req.ReplyID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reply_id"}) return } _, _ = h.pool.Exec(ctx, ` INSERT INTO board_moderation_actions (reply_id, moderator_id, action, reason) VALUES ($1, $2, 'flag', $3) `, replyUUID, userID, req.Reason) } else { _, _ = h.pool.Exec(ctx, ` INSERT INTO board_moderation_actions (entry_id, moderator_id, action, reason) VALUES ($1, $2, 'flag', $3) `, entryID, userID, req.Reason) } c.JSON(http.StatusOK, gin.H{"message": "content flagged for review"}) } // ── Helpers ─────────────────────────────────────────────────────────────── // isNeighborhoodAdmin checks if a user is an admin/owner of any neighborhood // group that covers the given coordinates. func (h *BoardHandler) isNeighborhoodAdmin(c *gin.Context, userID uuid.UUID, lat, long float64) bool { var exists bool _ = h.pool.QueryRow(c.Request.Context(), ` SELECT EXISTS( SELECT 1 FROM neighborhood_seeds ns JOIN group_members gm ON gm.group_id = ns.group_id WHERE gm.user_id = $1 AND gm.role IN ('owner', 'admin') AND ST_DWithin( ST_SetSRID(ST_Point($3, $2), 4326)::geography, ST_SetSRID(ST_Point(ns.lng, ns.lat), 4326)::geography, ns.radius_meters ) ) `, userID, lat, long).Scan(&exists) return exists } // aiModerateEntry runs AI moderation on a board entry asynchronously. // If flagged, it sets ai_flagged = true and hides the entry. func (h *BoardHandler) aiModerateEntry(entryID uuid.UUID, body string, authorID uuid.UUID) { if h.moderationService == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() score, reason, err := h.moderationService.AnalyzeContent(ctx, body, nil) if err != nil { log.Printf("[BoardAI] moderation error for entry %s: %v", entryID, err) return } // Always log AI decision for audit decision := "clean" if reason != "" { decision = "flag" } rawScore, _ := json.Marshal(score) h.moderationService.LogAIDecision(ctx, "board_entry", entryID, authorID, body, score, rawScore, decision, reason, "", nil) if reason == "" { return // clean } log.Printf("[BoardAI] entry %s flagged: %s", entryID, reason) _, _ = h.pool.Exec(ctx, ` UPDATE board_entries SET ai_flagged = TRUE, ai_flag_reason = $1, is_active = FALSE, removed_reason = 'AI auto-moderation: ' || $1, removed_at = NOW(), updated_at = NOW() WHERE id = $2 `, reason, entryID) // Log audit trail _, _ = h.pool.Exec(ctx, ` INSERT INTO board_moderation_actions (entry_id, moderator_id, action, reason, ai_engine, ai_reason) VALUES ($1, $2, 'remove', $3, 'auto', $3) `, entryID, authorID, "AI flagged: "+reason) } // aiModerateReply runs AI moderation on a board reply asynchronously. func (h *BoardHandler) aiModerateReply(replyID uuid.UUID, body string, authorID uuid.UUID) { if h.moderationService == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() score, reason, err := h.moderationService.AnalyzeContent(ctx, body, nil) if err != nil { log.Printf("[BoardAI] moderation error for reply %s: %v", replyID, err) return } // Always log AI decision for audit decision := "clean" if reason != "" { decision = "flag" } rawScore, _ := json.Marshal(score) h.moderationService.LogAIDecision(ctx, "board_reply", replyID, authorID, body, score, rawScore, decision, reason, "", nil) if reason == "" { return // clean } log.Printf("[BoardAI] reply %s flagged: %s", replyID, reason) _, _ = h.pool.Exec(ctx, ` UPDATE board_replies SET ai_flagged = TRUE, ai_flag_reason = $1, is_active = FALSE, removed_reason = 'AI auto-moderation: ' || $1, removed_at = NOW() WHERE id = $2 `, reason, replyID) // Log audit trail _, _ = h.pool.Exec(ctx, ` INSERT INTO board_moderation_actions (reply_id, moderator_id, action, reason, ai_engine, ai_reason) VALUES ($1, $2, 'remove', $3, 'auto', $3) `, replyID, authorID, "AI flagged: "+reason) }