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
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.
241 lines
7.3 KiB
Go
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
|
|
}
|