package controllers import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "gorm.io/gorm/clause" "fotbal-club/internal/models" "fotbal-club/internal/services" "fotbal-club/pkg/validation" ) type CommentController struct{ DB *gorm.DB } // Admin: list active bans // GET /api/v1/admin/comments/bans func (cc *CommentController) AdminListBans(c *gin.Context) { var bans []models.CommentBan // Active = until is NULL (permanent) OR until > now now := time.Now() if err := cc.DB.Where("until IS NULL OR until > ?", now).Order("created_at DESC").Find(&bans).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load bans"}) return } // Load users uids := make([]uint, 0, len(bans)) seen := map[uint]bool{} for _, b := range bans { if !seen[b.UserID] { uids = append(uids, b.UserID) seen[b.UserID] = true } } type userRow struct { ID uint FirstName string LastName string Email string Role string } users := map[uint]userRow{} if len(uids) > 0 { var rows []userRow _ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error for _, r := range rows { users[r.ID] = r } } usernameByID := map[uint]string{} if len(uids) > 0 { type prof struct { UserID uint Username string } var profs []prof _ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } } } type banOut struct { ID uint `json:"id"` UserID uint `json:"user_id"` Reason string `json:"reason"` Until *time.Time `json:"until"` CreatedAt time.Time `json:"created_at"` CreatedByID uint `json:"created_by_id"` User struct { ID uint `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Email string `json:"email"` Role string `json:"role"` Username string `json:"username,omitempty"` } `json:"user"` } out := make([]banOut, 0, len(bans)) for _, b := range bans { o := banOut{ID: b.ID, UserID: b.UserID, Reason: b.Reason, Until: b.Until, CreatedAt: b.CreatedAt, CreatedByID: b.CreatedByID} if u, ok := users[b.UserID]; ok { o.User.ID = u.ID o.User.FirstName = u.FirstName o.User.LastName = u.LastName o.User.Email = u.Email o.User.Role = u.Role } if v, ok := usernameByID[b.UserID]; ok { o.User.Username = v } out = append(out, o) } c.JSON(http.StatusOK, gin.H{"items": out}) } // Admin: lift a ban early by setting until = now // POST /api/v1/admin/comments/bans/:id/lift func (cc *CommentController) AdminLiftBan(c *gin.Context) { id := c.Param("id") now := time.Now() if err := cc.DB.Model(&models.CommentBan{}).Where("id = ?", id).Update("until", now).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to lift ban"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // 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 } // Ensure reactions table exists (best-effort) _ = cc.DB.AutoMigrate(&models.CommentReaction{}) // Validate reaction type against allowed values rt := strings.TrimSpace(body.Type) if err := validation.ValidateReactionType(rt); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } uidv, _ := c.Get("userID") userID := uidv.(uint) // Atomic upsert: enforce single reaction per (comment_id, user_id) r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt} if err := cc.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}}, DoUpdates: clause.Assignments(map[string]interface{}{"type": rt, "updated_at": time.Now()}), }).Create(&r).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"}) return } // Award a small amount of points for reactions (capped per day in service) svc := services.NewEngagementService(cc.DB) _, _ = svc.AwardPointsCapped(userID, 1, "comment_reacted", map[string]interface{}{"comment_id": cm.ID}) 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 } // Ensure reactions table exists (best-effort) _ = cc.DB.AutoMigrate(&models.CommentReaction{}) uidv, _ := c.Get("userID") _ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uidv.(uint)).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) { // Ensure tables exist (best-effort) _ = cc.DB.AutoMigrate(&models.Comment{}, &models.CommentReport{}, &models.CommentReaction{}) 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 } } // Compute admin likes (thumbs_up/like) per comment adminLiked := map[uint]bool{} if len(ids) > 0 { type al struct{ CommentID uint } var rows []al _ = cc.DB.Table("comment_reactions as cr"). Select("cr.comment_id"). Joins("JOIN users u ON u.id = cr.user_id"). Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}). Group("cr.comment_id"). Scan(&rows).Error for _, r := range rows { adminLiked[r.CommentID] = true } } // Prepare target labels (titles) for admin visibility: articles and events articleIDs := make([]uint, 0) eventIDs := make([]uint, 0) for _, it := range items { switch it.TargetType { case "article": if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil { articleIDs = append(articleIDs, uint(v)) } case "event": if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil { eventIDs = append(eventIDs, uint(v)) } } } articleTitleByID := map[uint]string{} if len(articleIDs) > 0 { type row struct { ID uint Title string } var rows []row _ = cc.DB.Table("articles").Select("id, title").Where("id IN ?", articleIDs).Scan(&rows).Error for _, r := range rows { articleTitleByID[r.ID] = r.Title } } eventTitleByID := map[uint]string{} if len(eventIDs) > 0 { type row struct { ID uint Title string } var rows []row _ = cc.DB.Table("events").Select("id, title").Where("id IN ?", eventIDs).Scan(&rows).Error for _, r := range rows { eventTitleByID[r.ID] = r.Title } } out := make([]commentOutput, 0, len(items)) for _, r := range items { co := toOutput(r) if v, ok := repCounts[r.ID]; ok { co.Reports = v } if adminLiked[r.ID] { co.AdminLiked = true } // Compose human label for target switch r.TargetType { case "article": if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil { if t, ok := articleTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" { co.TargetLabel = fmt.Sprintf("Článek: %s (#%s)", t, r.TargetID) } else { co.TargetLabel = fmt.Sprintf("Článek #%s", r.TargetID) } } else { co.TargetLabel = "Článek" } case "event": if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil { if t, ok := eventTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" { co.TargetLabel = fmt.Sprintf("Aktivita: %s (#%s)", t, r.TargetID) } else { co.TargetLabel = fmt.Sprintf("Aktivita #%s", r.TargetID) } } else { co.TargetLabel = "Aktivita" } case "gallery_album": co.TargetLabel = fmt.Sprintf("Galerie album #%s", r.TargetID) case "youtube_video": co.TargetLabel = fmt.Sprintf("YouTube video %s", r.TargetID) default: co.TargetLabel = fmt.Sprintf("%s #%s", r.TargetType, r.TargetID) } 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) { // Only pending requests var items []models.UnbanRequest _ = cc.DB.Where("status = ?", "pending").Order("created_at DESC").Find(&items).Error // Load users and usernames uids := make([]uint, 0, len(items)) seen := map[uint]bool{} for _, it := range items { if !seen[it.UserID] { uids = append(uids, it.UserID) seen[it.UserID] = true } } type userRow struct { ID uint FirstName string LastName string Email string Role string } users := map[uint]userRow{} if len(uids) > 0 { var rows []userRow _ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error for _, r := range rows { users[r.ID] = r } } usernameByID := map[uint]string{} if len(uids) > 0 { type prof struct { UserID uint Username string } var profs []prof _ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } } } type unbanOut struct { ID uint `json:"id"` UserID uint `json:"user_id"` Message string `json:"message"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` ResolvedByID *uint `json:"resolved_by_id,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` User struct { ID uint `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Email string `json:"email"` Role string `json:"role"` Username string `json:"username,omitempty"` } `json:"user"` } out := make([]unbanOut, 0, len(items)) for _, it := range items { var u userRow if r, ok := users[it.UserID]; ok { u = r } o := unbanOut{ ID: it.ID, UserID: it.UserID, Message: it.Message, Status: it.Status, CreatedAt: it.CreatedAt, ResolvedByID: it.ResolvedByID, ResolvedAt: it.ResolvedAt, } o.User.ID = u.ID o.User.FirstName = u.FirstName o.User.LastName = u.LastName o.User.Email = u.Email o.User.Role = u.Role if v, ok := usernameByID[it.UserID]; ok { o.User.Username = v } out = append(out, o) } c.JSON(http.StatusOK, gin.H{"items": out}) } // 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"` TargetLabel string `json:"target_label,omitempty"` ParentID *uint `json:"parent_id,omitempty"` Content string `json:"content"` ContentHTML string `json:"content_html,omitempty"` 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"` AdminLiked bool `json:"admin_liked,omitempty"` } type userSlim struct { ID uint `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Role string `json:"role"` Username string `json:"username,omitempty"` 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, 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 // Visibility rules: // - Public/unauthenticated: only visible // - Authenticated non-admin: visible + own hidden (awaiting moderation) // - Admin: all role, _ := c.Get("userRole") uid, _ := c.Get("userID") var where string var args []interface{} if role == "admin" { where = "target_type = ? AND target_id = ?" args = []interface{}{targetType, targetID} } else if uid != nil { where = "target_type = ? AND target_id = ? AND (status = 'visible' OR (status = 'hidden' AND user_id = ?))" args = []interface{}{targetType, targetID, uid} } else { where = "target_type = ? AND target_id = ? AND status = 'visible'" args = []interface{}{targetType, targetID} } q := cc.DB.Model(&models.Comment{}).Where(where, args...) 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(where, args...). 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 } } } // Admin liked map adminLiked := map[uint]bool{} if len(ids) > 0 { type al struct{ CommentID uint } var rows []al _ = cc.DB.Table("comment_reactions as cr"). Select("cr.comment_id"). Joins("JOIN users u ON u.id = cr.user_id"). Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}). Group("cr.comment_id"). Scan(&rows).Error for _, r := range rows { adminLiked[r.CommentID] = true } } // Preload user profiles for username + avatar (prefer animated when available) type up struct { UserID uint AvatarURL string AnimatedAvatarURL string Username string } profByUser := map[uint]up{} if len(userIDs) > 0 { var profs []up _ = cc.DB.Table("user_profiles").Select("user_id, avatar_url, animated_avatar_url, username").Where("user_id IN ?", userIDs).Scan(&profs).Error for _, p := range profs { profByUser[p.UserID] = p } } for _, r := range rows { co := toOutput(r) if role != "admin" { co.ContentHTML = services.MaskBadWordsHTML(co.Content) } if co.User.ID != 0 { if p, ok := profByUser[co.User.ID]; ok { if strings.TrimSpace(p.Username) != "" { co.User.Username = p.Username } if strings.TrimSpace(p.AnimatedAvatarURL) != "" { co.User.AvatarURL = p.AnimatedAvatarURL } else { co.User.AvatarURL = p.AvatarURL } } } 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 } } if adminLiked[r.ID] { co.AdminLiked = true } 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 } if in.ParentID != nil { var parent models.Comment if err := cc.DB.First(&parent, *in.ParentID).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Rodičovský komentář nenalezen"}) return } if parent.TargetType != in.TargetType || parent.TargetID != in.TargetID { c.JSON(http.StatusBadRequest, gin.H{"error": "Rodičovský komentář neodpovídá cíli"}) 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 moderation (do not alter stored content) score, rules := services.EvaluateSpamScore(content) status := "visible" // Moderation only if sensitive terms detected if ok, _ := services.ContainsSensitiveWords(content); ok { status = "hidden" } rulesJSON, _ := json.Marshal(rules) cm := models.Comment{ TargetType: in.TargetType, TargetID: in.TargetID, UserID: userID, ParentID: in.ParentID, Content: content, 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 (capped per day) if status == "visible" { svc := services.NewEngagementService(cc.DB) _, _ = svc.AwardPointsCapped(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 } // Re-evaluate basic spam, keep original content (masking is done on output) score, rules := services.EvaluateSpamScore(content) now := time.Now() cm.Content = content 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 }