Files
MyClub/internal/controllers/comment_controller.go
T
Tomas Dvorak f3db65d350 dev day #90 🥳
2025-11-12 20:31:37 +01:00

759 lines
31 KiB
Go

package controllers
import (
"net/http"
"strings"
"time"
"encoding/json"
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
)
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
}
uid, _ := c.Get("userID")
// Upsert reaction to ensure exactly one reaction per (comment_id,user_id)
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
if err := cc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{"type": r.Type, "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(uid.(uint), 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
}
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) {
// 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"`
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 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 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 (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
}
// 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
}