Files
MyClub/internal/controllers/comment_controller.go
T
Tomas Dvorak b9cea0cd77 dev day #79
2025-11-02 01:04:02 +01:00

509 lines
20 KiB
Go

package controllers
import (
"net/http"
"strings"
"time"
"encoding/json"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
)
type CommentController struct{ DB *gorm.DB }
// ReportComment allows a user to report a comment with an optional reason
func (cc *CommentController) ReportComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Reason string `json:"reason"` }
_ = c.ShouldBindJSON(&body)
uid, _ := c.Get("userID")
// Prevent duplicate reports by same user
var exists models.CommentReport
if err := cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).First(&exists).Error; err == nil && exists.ID != 0 {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
rep := models.CommentReport{ CommentID: cm.ID, UserID: uid.(uint), Reason: strings.TrimSpace(body.Reason) }
if err := cc.DB.Create(&rep).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// React to a comment (auth)
func (cc *CommentController) React(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Type string `json:"type"` }
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Type) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
uid, _ := c.Get("userID")
// delete previous reaction for this user
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
// create new
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
if err := cc.DB.Create(&r).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Remove reaction (auth)
func (cc *CommentController) Unreact(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
uid, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list all comments with filters
func (cc *CommentController) AdminList(c *gin.Context) {
var items []models.Comment
q := cc.DB.Preload("User").Model(&models.Comment{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("target_type")); v != "" { q = q.Where("target_type = ?", v) }
if v := strings.TrimSpace(c.Query("target_id")); v != "" { q = q.Where("target_id = ?", v) }
if v := strings.TrimSpace(c.Query("user_id")); v != "" { q = q.Where("user_id = ?", v) }
page := parseIntDefault(c.Query("page"), 1)
size := parseIntDefault(c.Query("page_size"), 50)
if size > 200 { size = 200 }
var total int64
_ = q.Count(&total).Error
_ = q.Order("created_at DESC").Offset((page-1)*size).Limit(size).Find(&items).Error
// Preload reports counts
ids := make([]uint, 0, len(items))
for _, it := range items { ids = append(ids, it.ID) }
repCounts := map[uint]int{}
if len(ids) > 0 {
type pr struct{ CommentID uint; Cnt int }
var rows []pr
_ = cc.DB.Table("comment_reports").Select("comment_id, COUNT(*) as cnt").Where("comment_id IN ?", ids).Group("comment_id").Scan(&rows).Error
for _, r := range rows { repCounts[r.CommentID] = r.Cnt }
}
out := make([]commentOutput, 0, len(items))
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; out = append(out, co) }
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
// Admin: update comment status (visible|hidden)
func (cc *CommentController) AdminUpdateStatus(c *gin.Context) {
id := c.Param("id")
var body struct{ Status string `json:"status"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
if body.Status != "visible" && body.Status != "hidden" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid status"}); return }
if err := cc.DB.Model(&models.Comment{}).Where("id = ?", id).Update("status", body.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: ban user for period
func (cc *CommentController) AdminBanUser(c *gin.Context) {
var body struct { UserID uint `json:"user_id"`; Reason string `json:"reason"`; DurationHours int `json:"duration_hours"` }
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
var until *time.Time
if body.DurationHours > 0 { t := time.Now().Add(time.Duration(body.DurationHours) * time.Hour); until = &t }
uid, _ := c.Get("userID")
ban := models.CommentBan{ UserID: body.UserID, Reason: strings.TrimSpace(body.Reason), Until: until, CreatedByID: uid.(uint) }
if err := cc.DB.Create(&ban).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Create unban request (auth)
func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
var body struct { Message string `json:"message"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
uid, _ := c.Get("userID")
req := models.UnbanRequest{ UserID: uid.(uint), Message: strings.TrimSpace(body.Message) }
if err := cc.DB.Create(&req).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list unban requests
func (cc *CommentController) AdminListUnban(c *gin.Context) {
var items []models.UnbanRequest
_ = cc.DB.Order("created_at DESC").Find(&items).Error
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: resolve unban request
func (cc *CommentController) AdminResolveUnban(c *gin.Context) {
id := c.Param("id")
var body struct { Action string `json:"action"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
uid, _ := c.Get("userID")
var req models.UnbanRequest
if err := cc.DB.First(&req, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
if body.Action != "approve" && body.Action != "reject" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return }
status := map[string]string{"approve":"approved","reject":"rejected"}[body.Action]
now := time.Now()
if err := cc.DB.Model(&req).Updates(map[string]interface{}{"status": status, "resolved_by_id": uid.(uint), "resolved_at": &now}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
// If approved, remove bans (set until = now)
if status == "approved" {
_ = cc.DB.Model(&models.CommentBan{}).Where("user_id = ? AND (until IS NULL OR until > ?)", req.UserID, time.Now()).Update("until", now).Error
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func NewCommentController(db *gorm.DB) *CommentController { return &CommentController{DB: db} }
var allowedTargetTypes = map[string]bool{
"article": true,
"event": true,
"gallery_album": true,
"youtube_video": true,
}
type commentOutput struct {
ID uint `json:"id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
ParentID *uint `json:"parent_id,omitempty"`
Content string `json:"content"`
Status string `json:"status"`
IsEdited bool `json:"is_edited"`
EditedAt *time.Time `json:"edited_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User userSlim `json:"user"`
Reactions map[string]int `json:"reactions"`
MyReaction string `json:"my_reaction,omitempty"`
SpamScore float32 `json:"spam_score,omitempty"`
SpamRules []string `json:"spam_rules,omitempty"`
Reports int `json:"reports,omitempty"`
}
type userSlim struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
AvatarURL string `json:"avatar_url,omitempty"`
}
func toOutput(c models.Comment) commentOutput {
out := commentOutput{
ID: c.ID,
TargetType: c.TargetType,
TargetID: c.TargetID,
ParentID: c.ParentID,
Content: c.Content,
Status: c.Status,
IsEdited: c.IsEdited,
EditedAt: c.EditedAt,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
User: userSlim{
ID: c.User.ID,
FirstName: c.User.FirstName,
LastName: c.User.LastName,
Email: c.User.Email,
Role: c.User.Role,
},
SpamScore: c.SpamScore,
}
if strings.TrimSpace(c.SpamRules) != "" {
var arr []string
if err := json.Unmarshal([]byte(c.SpamRules), &arr); err == nil { out.SpamRules = arr }
}
return out
}
// GetComments (public): list comments for a target with pagination
// GET /comments?target_type=...&target_id=...&page=1&page_size=20
func (cc *CommentController) GetComments(c *gin.Context) {
// Ensure table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{})
targetType := strings.TrimSpace(c.Query("target_type"))
targetID := strings.TrimSpace(c.Query("target_id"))
if !allowedTargetTypes[targetType] || targetID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing or invalid target_type/target_id"})
return
}
page := parseIntDefault(c.Query("page"), 1)
pageSize := parseIntDefault(c.Query("page_size"), 20)
if pageSize > 100 { pageSize = 100 }
if page < 1 { page = 1 }
var total int64
q := cc.DB.Model(&models.Comment{}).Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible")
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var rows []models.Comment
if err := cc.DB.Preload("User").Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible").
Order("created_at ASC").
Offset((page-1)*pageSize).Limit(pageSize).
Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Build reactions map per comment
out := make([]commentOutput, 0, len(rows))
ids := make([]uint, 0, len(rows))
userIDs := make([]uint, 0, len(rows))
for _, r := range rows { ids = append(ids, r.ID) }
seenU := map[uint]bool{}
for _, r := range rows { if r.UserID != 0 && !seenU[r.UserID] { userIDs = append(userIDs, r.UserID); seenU[r.UserID] = true } }
reactionCounts := make(map[uint]map[string]int)
if len(ids) > 0 {
type rc struct{ CommentID uint; Type string; Cnt int }
var agg []rc
// aggregate per type
if err := cc.DB.Table("comment_reactions").Select("comment_id, type, COUNT(*) as cnt").
Where("comment_id IN ?", ids).Group("comment_id, type").Scan(&agg).Error; err == nil {
for _, a := range agg {
if reactionCounts[a.CommentID] == nil { reactionCounts[a.CommentID] = map[string]int{} }
reactionCounts[a.CommentID][a.Type] = a.Cnt
}
}
}
var myReactions map[uint]string
if uid, ok := c.Get("userID"); ok {
var rs []models.CommentReaction
if err := cc.DB.Where("user_id = ? AND comment_id IN ?", uid, ids).Find(&rs).Error; err == nil {
myReactions = make(map[uint]string, len(rs))
for _, r := range rs { myReactions[r.CommentID] = r.Type }
}
}
// Preload user profiles for avatar (prefer animated when available)
avatarByUser := map[uint]string{}
if len(userIDs) > 0 {
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string }
var profs []up
_ = cc.DB.Table("user_profiles").Select("user_id, avatar_url, animated_avatar_url").Where("user_id IN ?", userIDs).Scan(&profs).Error
for _, p := range profs {
if strings.TrimSpace(p.AnimatedAvatarURL) != "" {
avatarByUser[p.UserID] = p.AnimatedAvatarURL
} else {
avatarByUser[p.UserID] = p.AvatarURL
}
}
}
for _, r := range rows {
co := toOutput(r)
if co.User.ID != 0 {
if av, ok := avatarByUser[co.User.ID]; ok { co.User.AvatarURL = av }
}
if rc, ok := reactionCounts[r.ID]; ok { co.Reactions = rc } else { co.Reactions = map[string]int{} }
if myReactions != nil { if t, ok := myReactions[r.ID]; ok { co.MyReaction = t } }
out = append(out, co)
}
c.JSON(http.StatusOK, gin.H{
"items": out,
"total": total,
"page": page,
"page_size": pageSize,
})
}
type createCommentInput struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Content string `json:"content"`
ParentID *uint `json:"parent_id"`
}
// CreateComment (auth required)
func (cc *CommentController) CreateComment(c *gin.Context) {
// Ensure table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{})
var in createCommentInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
in.TargetType = strings.TrimSpace(in.TargetType)
in.TargetID = strings.TrimSpace(in.TargetID)
content := strings.TrimSpace(in.Content)
if !allowedTargetTypes[in.TargetType] || in.TargetID == "" || len(content) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing or invalid fields"})
return
}
if len(content) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Komentář je příliš krátký (min. 6 znaků)"})
return
}
if len(content) > 2000 { // hard cap
c.JSON(http.StatusBadRequest, gin.H{"error": "Komentář je příliš dlouhý (max 2000 znaků)"})
return
}
userIDv, _ := c.Get("userID")
userID := userIDv.(uint)
// Check active bans
var activeBan models.CommentBan
if err := cc.DB.Where("user_id = ? AND (until IS NULL OR until > ?)", userID, time.Now()).Order("created_at DESC").First(&activeBan).Error; err == nil && activeBan.ID != 0 {
// User is banned
until := "trvale"
if activeBan.Until != nil { until = activeBan.Until.Format(time.RFC3339) }
c.JSON(http.StatusForbidden, gin.H{"error": "Váš účet má omezené komentování.", "until": until})
return
}
// Spam evaluation and bad words filtering
score, rules := services.EvaluateSpamScore(content)
filtered, _ := services.FilterBadWords(content)
status := "visible"
// Moderation only if sensitive terms detected
if ok, _ := services.ContainsSensitiveWords(filtered); ok {
status = "hidden"
}
rulesJSON, _ := json.Marshal(rules)
cm := models.Comment{
TargetType: in.TargetType,
TargetID: in.TargetID,
UserID: userID,
ParentID: in.ParentID,
Content: filtered,
Status: status,
SpamScore: float32(score),
SpamRules: string(rulesJSON),
}
if err := cc.DB.Create(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment"})
return
}
// Award engagement points for visible comment
if status == "visible" {
svc := services.NewEngagementService(cc.DB)
_, _ = svc.AwardPoints(userID, 5, "comment_create", map[string]interface{}{"comment_id": cm.ID})
_ = svc.CheckAndAwardAchievements(userID)
}
// Reload with user
var out models.Comment
_ = cc.DB.Preload("User").First(&out, cm.ID).Error
c.JSON(http.StatusCreated, toOutput(out))
}
type updateCommentInput struct {
Content string `json:"content"`
}
// UpdateComment (auth: owner or admin)
func (cc *CommentController) UpdateComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.Preload("User").First(&cm, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Permission: owner or admin
role, _ := c.Get("userRole")
uidv, _ := c.Get("userID")
if role != "admin" && uidv.(uint) != cm.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
var in updateCommentInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
content := strings.TrimSpace(in.Content)
if len(content) == 0 || len(content) > 2000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatný obsah"})
return
}
// Re-check ban
var activeBan models.CommentBan
if err := cc.DB.Where("user_id = ? AND (until IS NULL OR until > ?)", cm.UserID, time.Now()).First(&activeBan).Error; err == nil && activeBan.ID != 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "Váš účet má omezené komentování."})
return
}
// Filter & re-evaluate basic spam (do not auto-hide unless sensitive)
score, rules := services.EvaluateSpamScore(content)
filtered, _ := services.FilterBadWords(content)
now := time.Now()
cm.Content = filtered
cm.IsEdited = true
cm.EditedAt = &now
cm.SpamScore = float32(score)
if b, err := json.Marshal(rules); err == nil { cm.SpamRules = string(b) }
if err := cc.DB.Save(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update comment"})
return
}
c.JSON(http.StatusOK, toOutput(cm))
}
// DeleteComment (auth: owner or admin)
func (cc *CommentController) DeleteComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Permission: owner or admin
role, _ := c.Get("userRole")
uidv, _ := c.Get("userID")
if role != "admin" && uidv.(uint) != cm.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
if err := cc.DB.Delete(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete comment"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// helpers
func parseIntDefault(s string, def int) int {
if s == "" { return def }
n := 0
for _, ch := range s { if ch < '0' || ch > '9' { return def } }
for i := 0; i < len(s); i++ { n = n*10 + int(s[i]-'0') }
if n <= 0 { return def }
return n
}