veza/veza-backend-api/internal/services/comment_service.go
senke 41b5f6c455
Some checks failed
Veza CI / Backend (Go) (push) Waiting to run
Veza CI / Frontend (Web) (push) Waiting to run
Veza CI / Notify on failure (push) Blocked by required conditions
Security Scan / Secret Scanning (gitleaks) (push) Failing after 3m4s
Veza CI / Rust (Stream Server) (push) Has been cancelled
Backend API CI / test-integration (push) Failing after 11m59s
Backend API CI / test-unit (push) Failing after 12m1s
style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
c96edd692) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

241 lines
7.3 KiB
Go

package services
import (
"context"
"errors"
"time"
"veza-backend-api/internal/models"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
type CommentService struct {
db *gorm.DB
logger *zap.Logger
moderationService *CommentModerationService // v0.10.3 F201: optional keyword moderation
}
func NewCommentService(db *gorm.DB, logger *zap.Logger) *CommentService {
return &CommentService{
db: db,
logger: logger,
}
}
// SetModerationService sets the optional moderation service for comment content (v0.10.3 F201).
func (s *CommentService) SetModerationService(mod *CommentModerationService) {
s.moderationService = mod
}
// CreateComment creates a new comment on a track
func (s *CommentService) CreateComment(ctx context.Context, trackID uuid.UUID, userID uuid.UUID, content string, timestamp float64, parentID *uuid.UUID) (*models.TrackComment, error) { // Updated trackID and parentID to uuid.UUID
// v0.10.3 F201: Moderation by keyword list (deterministic, no ML)
if s.moderationService != nil {
banned, err := s.moderationService.ContainsBannedKeyword(ctx, content)
if err != nil {
s.logger.Warn("moderation check failed", zap.Error(err))
// Continue without moderation on error (fail-open for availability)
} else if banned {
return nil, ErrModerationRejected
}
}
// Verify if track exists
var track models.Track
if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { // Updated query
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTrackNotFound
}
return nil, err
}
// Verify if parent comment exists (if reply)
if parentID != nil {
var parent models.TrackComment
if err := s.db.WithContext(ctx).First(&parent, "id = ?", *parentID).Error; err != nil { // Updated query
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrParentCommentNotFound
}
return nil, err
}
// Ensure parent belongs to the same track
if parent.TrackID != trackID {
return nil, ErrParentTrackMismatch
}
}
comment := &models.TrackComment{
TrackID: trackID,
UserID: userID,
Content: content,
Timestamp: timestamp,
ParentID: parentID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.db.WithContext(ctx).Create(comment).Error; err != nil {
s.logger.Error("Failed to create comment", zap.Error(err))
return nil, err
}
// Preload user info for response
if err := s.db.WithContext(ctx).Preload("User").First(comment, comment.ID).Error; err != nil {
return comment, nil // Return comment without user info if preload fails
}
s.logger.Info("Comment created",
zap.Any("comment_id", comment.ID),
zap.Any("track_id", trackID),
zap.String("user_id", userID.String()))
return comment, nil
}
// GetTrackCreatorID returns the creator user ID of a track (Phase 2.2)
func (s *CommentService) GetTrackCreatorID(ctx context.Context, trackID uuid.UUID) (uuid.UUID, error) {
var track models.Track
if err := s.db.WithContext(ctx).Select("user_id").First(&track, "id = ?", trackID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return uuid.Nil, ErrTrackNotFound
}
return uuid.Nil, err
}
return track.UserID, nil
}
// GetComments retrieves comments for a track
func (s *CommentService) GetComments(ctx context.Context, trackID uuid.UUID, page, limit int) ([]models.TrackComment, int64, error) { // Updated trackID to uuid.UUID
var comments []models.TrackComment
var total int64
offset := (page - 1) * limit
// Count total top-level comments (or all comments? usually top-level for pagination, replies fetched separately or nested)
// Here we fetch all top-level comments
query := s.db.WithContext(ctx).Model(&models.TrackComment{}).Where("track_id = ? AND parent_id IS NULL", trackID)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Fetch comments with user info and replies
// Note: Deep nesting of replies might require recursive query or multiple queries.
// For simplicity, we just preload direct replies or let frontend handle threading if flat list.
// Assuming flat list of top level + preloaded replies?
// Let's just fetch top level and preload their replies one level deep for now
err := query.
Preload("User").
Preload("Replies").
Preload("Replies.User").
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&comments).Error
if err != nil {
s.logger.Error("Failed to get comments", zap.Error(err))
return nil, 0, err
}
return comments, total, nil
}
// UpdateComment updates a comment
func (s *CommentService) UpdateComment(ctx context.Context, commentID uuid.UUID, userID uuid.UUID, content string) (*models.TrackComment, error) { // Updated commentID to uuid.UUID
var comment models.TrackComment
if err := s.db.WithContext(ctx).First(&comment, "id = ?", commentID).Error; err != nil { // Updated query
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCommentNotFound
}
return nil, err
}
// Check permission
if comment.UserID != userID {
return nil, ErrForbidden
}
comment.Content = content
comment.IsEdited = true
comment.UpdatedAt = time.Now()
if err := s.db.WithContext(ctx).Save(&comment).Error; err != nil {
s.logger.Error("Failed to update comment", zap.Error(err))
return nil, err
}
s.logger.Info("Comment updated",
zap.Any("comment_id", comment.ID), // Changed to zap.Any for uuid.UUID
zap.String("user_id", userID.String()))
return &comment, nil
}
// GetReplies retrieves replies for a given parent comment ID
func (s *CommentService) GetReplies(ctx context.Context, parentID uuid.UUID, page, limit int) ([]models.TrackComment, int64, error) { // Updated parentID to uuid.UUID
var replies []models.TrackComment
var total int64
offset := (page - 1) * limit
// Verify if parent comment exists
var parent models.TrackComment
if err := s.db.WithContext(ctx).First(&parent, "id = ?", parentID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, ErrParentCommentNotFound
}
return nil, 0, err
}
// Count total replies
query := s.db.WithContext(ctx).Model(&models.TrackComment{}).Where("parent_id = ?", parentID)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Fetch replies with user info
err := query.
Preload("User").
Order("created_at ASC"). // Order by oldest first
Limit(limit).
Offset(offset).
Find(&replies).Error
if err != nil {
s.logger.Error("Failed to get replies", zap.Error(err))
return nil, 0, err
}
return replies, total, nil
}
// DeleteComment deletes a comment
// MIGRATION UUID: userID migré vers uuid.UUID, commentID reste int64
func (s *CommentService) DeleteComment(ctx context.Context, commentID uuid.UUID, userID uuid.UUID, isAdmin bool) error { // Updated commentID to uuid.UUID
var comment models.TrackComment
if err := s.db.WithContext(ctx).First(&comment, "id = ?", commentID).Error; err != nil { // Updated query
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrCommentNotFound
}
return err
}
// Check permission
if comment.UserID != userID && !isAdmin {
return ErrForbidden
}
// Soft delete or hard delete? Model has DeletedAt so soft delete
if err := s.db.WithContext(ctx).Delete(&comment).Error; err != nil {
s.logger.Error("Failed to delete comment", zap.Error(err))
return err
}
return nil
}