Files
MyClub/internal/controllers/comment_controller.go
T
Tomas Dvorak f5b6f83974 dev day #99
2025-11-21 08:44:44 +01:00

966 lines
29 KiB
Go

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
}