Handler.cs 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Notification;
  4. using Domain.Entities.Feed;
  5. using Domain.Entities.Notifications.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. using SharedKernel.Results;
  8. namespace Application.Features.Api.Feed.CreateComment;
  9. public sealed class Handler(IAppDbContext db, INotificationService notificationService) : ICommandHandler<Command, Result<Response>>
  10. {
  11. private const int MaxContentLength = 2000;
  12. private const int MaxDepth = 10;
  13. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  14. {
  15. if (request.MemberID <= 0)
  16. {
  17. return Result.Failure<Response>(Error.Problem("Feed.Comment.MemberRequired", "로그인이 필요합니다."));
  18. }
  19. if (string.IsNullOrWhiteSpace(request.Content))
  20. {
  21. return Result.Failure<Response>(Error.Problem("Feed.Comment.ContentRequired", "댓글 내용을 입력해주세요."));
  22. }
  23. var content = request.Content.Trim();
  24. if (content.Length > MaxContentLength)
  25. {
  26. return Result.Failure<Response>(Error.Problem("Feed.Comment.ContentTooLong", $"댓글은 최대 {MaxContentLength}자까지 입력 가능합니다."));
  27. }
  28. var post = await db.FeedPost.FirstOrDefaultAsync(p => p.ID == request.PostID && !p.IsDeleted, ct);
  29. if (post is null)
  30. {
  31. return Result.Failure<Response>(Error.NotFound("Feed.PostNotFound", "게시글을 찾을 수 없습니다."));
  32. }
  33. sbyte depth = 0;
  34. FeedComment? parent = null;
  35. if (request.ParentCommentID is int parentID && parentID > 0)
  36. {
  37. parent = await db.FeedComment.FirstOrDefaultAsync(c => c.ID == parentID && c.FeedPostID == request.PostID, ct);
  38. if (parent is null)
  39. {
  40. return Result.Failure<Response>(Error.NotFound("Feed.ParentCommentNotFound", "부모 댓글을 찾을 수 없습니다."));
  41. }
  42. depth = (sbyte)Math.Min(parent.Depth + 1, MaxDepth);
  43. }
  44. var comment = new FeedComment
  45. {
  46. FeedPostID = request.PostID,
  47. MemberID = request.MemberID,
  48. ParentID = parent?.ID,
  49. Depth = depth,
  50. Content = content
  51. };
  52. await db.FeedComment.AddAsync(comment, ct);
  53. post.Comments++;
  54. if (parent is not null)
  55. {
  56. parent.Replies++;
  57. }
  58. await db.SaveChangesAsync(ct);
  59. // ── 알림: 부모 댓글 작성자 + 포스트 작성자 (본인 제외, 중복 제외) ──
  60. var author = await db.Member.AsNoTracking().Where(m => m.ID == request.MemberID).Select(m => new { m.Name, m.SID }).FirstOrDefaultAsync(ct);
  61. var notifyTargets = new HashSet<int>();
  62. if (parent is not null && parent.MemberID != request.MemberID)
  63. {
  64. notifyTargets.Add(parent.MemberID);
  65. }
  66. if (post.MemberID != request.MemberID)
  67. {
  68. notifyTargets.Add(post.MemberID);
  69. }
  70. foreach (var targetID in notifyTargets)
  71. {
  72. await notificationService.SendAsync(
  73. memberID: targetID,
  74. type: NotificationType.NewFeedComment,
  75. title: parent is not null ? "새 답글" : "새 댓글",
  76. message: $"{author?.Name ?? author?.SID ?? "누군가"}님이 댓글을 남겼습니다.",
  77. actionUrl: $"/feed/post/{request.PostID}",
  78. relatedType: "FeedComment",
  79. relatedID: comment.ID,
  80. ct: ct
  81. );
  82. }
  83. return new Response(comment.ID);
  84. }
  85. }