From d3b13f3b24770a71dd6a4f01dc8fa74d0071ff31 Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:39:38 +0300 Subject: [PATCH 1/3] fix(discussions): take discussion ID from route when commenting a discussion --- .../v1/Controllers/DiscussionsController.cs | 10 ++++++---- .../DTOs/CommentRequest.cs | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs index 6aedbba..da06be5 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs @@ -49,7 +49,7 @@ public async Task> Search( await discussionsService.SearchAsync(authorId, User.GetUserId(), offset, count); /// - /// Retrieves top-level comments for a specified discussion with pagination. + /// Retrieves comments for a specified discussion with configurable tree traversal and pagination. /// /// The unique identifier of the discussion. /// When true, returns all nested replies in a flat structure; otherwise returns direct children only. @@ -70,6 +70,7 @@ public async Task> GetComments( /// /// Creates a new top-level comment in a discussion. Requires authenticated user and triggers real-time notifications. /// + /// The unique identifier of the discussion being commented. /// The comment content and target discussion identifier. /// The newly created top-level comment details. [HttpPost("{discussionId}/comments"), Authorize] @@ -78,12 +79,13 @@ public async Task> GetComments( [ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status403Forbidden)] [ProducesResponseType(Status500InternalServerError)] - public async Task> Reply([FromBody] CommentRequest request) + public async Task> Comment([FromRoute] string discussionId, [FromBody] CommentRequest request) { - var response = await commentsService.ReplyToDiscussionAsync(request.DiscussionId, User.GetRequiredUserId(), request.Content); + var response = await commentsService.ReplyToDiscussionAsync(discussionId, User.GetRequiredUserId(), request.Content); + // TODO: handle exceptions and move to a better place _ = commentHub.Clients - .Group(CommentsHub.GroupNames.NewCommentInDiscussion(request.DiscussionId)) + .Group(CommentsHub.GroupNames.NewCommentInDiscussion(discussionId)) .SendCoreAsync(CommentsHub.Events.NewComment.ToString(), [response]); return CreatedAtAction(nameof(CommentsController.GetById), "Comments", new { commentId = response.Id }, response); diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs b/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs index 141df88..f8a993d 100644 --- a/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs +++ b/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs @@ -1,3 +1,3 @@ namespace CrowdParlay.Social.Application.DTOs; -public record CommentRequest(string DiscussionId, string Content); \ No newline at end of file +public record CommentRequest(string Content); \ No newline at end of file From 9bdb461f74a8671a38a37d6cd5ee2a96c6f8b44c Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:40:46 +0300 Subject: [PATCH 2/3] feat(comments): add subject ID field to comment response model --- .../v1/Controllers/CommentsController.cs | 2 +- src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs | 1 + src/CrowdParlay.Social.Application/DTOs/ReplyRequest.cs | 3 --- src/CrowdParlay.Social.Application/Services/CommentsService.cs | 1 + .../CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs | 1 + 5 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 src/CrowdParlay.Social.Application/DTOs/ReplyRequest.cs diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs index c0c64d3..6f91c6b 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs @@ -60,7 +60,7 @@ public async Task> GetReplies( [ProducesResponseType(Status403Forbidden)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] - public async Task> Reply([FromRoute] string commentId, [FromBody] ReplyRequest request) + public async Task> Reply([FromRoute] string commentId, [FromBody] CommentRequest request) { var response = await commentsService.ReplyToCommentAsync(commentId, User.GetRequiredUserId(), request.Content); return CreatedAtAction(nameof(GetById), new { commentId = response.Id }, response); diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs b/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs index 2eaa608..eea59ee 100644 --- a/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs +++ b/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs @@ -3,6 +3,7 @@ namespace CrowdParlay.Social.Application.DTOs; public class CommentResponse { public required string Id { get; set; } + public required string SubjectId { get; set; } public required string Content { get; set; } public required AuthorResponse? Author { get; set; } public required DateTimeOffset CreatedAt { get; set; } diff --git a/src/CrowdParlay.Social.Application/DTOs/ReplyRequest.cs b/src/CrowdParlay.Social.Application/DTOs/ReplyRequest.cs deleted file mode 100644 index ef28280..0000000 --- a/src/CrowdParlay.Social.Application/DTOs/ReplyRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace CrowdParlay.Social.Application.DTOs; - -public record ReplyRequest(string Content); \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Services/CommentsService.cs b/src/CrowdParlay.Social.Application/Services/CommentsService.cs index 1e18d94..10f715a 100644 --- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs +++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs @@ -106,6 +106,7 @@ private async Task> EnrichAsync(IReadOnlyList new CommentResponse { Id = comment.Id, + SubjectId = comment.SubjectId, Content = comment.Content, Author = authorsById[comment.AuthorId].Adapt(), CreatedAt = comment.CreatedAt, diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs index 3278be6..b59601a 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs @@ -24,6 +24,7 @@ public async Task ListenToNewCommentsInDiscussion() var expectedComment = new CommentResponse { Id = ObjectId.GenerateNewId().ToString(), + SubjectId = discussionId, Content = "Sample comment.", Author = new AuthorResponse { From 86a86274223baca106758e396e8f62c96af79065 Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Thu, 12 Jun 2025 13:14:22 +0300 Subject: [PATCH 3/3] feat(reactions): omit zero-value reaction counters --- .../Services/SubjectsService.cs | 9 +++++---- .../Abstractions/ISubjectsRepository.cs | 2 +- .../Entities/Comment.cs | 9 +++++++-- .../Entities/Discussion.cs | 9 +++++++-- .../Services/CommentsRepository.cs | 4 ++-- .../Services/DiscussionsRepository.cs | 4 ++-- .../Services/GenericSubjectsRepository.cs | 17 +++++++---------- .../Tests/ReactionsTests.cs | 2 -- 8 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/CrowdParlay.Social.Application/Services/SubjectsService.cs b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs index 7022d98..13b1eac 100644 --- a/src/CrowdParlay.Social.Application/Services/SubjectsService.cs +++ b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs @@ -19,9 +19,10 @@ public async Task SetReactionsAsync(string subjectId, Guid authorId, ISet new KeyValuePair(reaction, 1)); + var removedReactionsDiff = oldReactions.Except(newReactions).Select(reaction => new KeyValuePair(reaction, -1)); + var reactionsDiff = addedReactionsDiff.Concat(removedReactionsDiff).ToDictionary(); + await subjectsRepository.UpdateReactionCountersAsync(subjectId, reactionsDiff); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs index 4a9e5e6..a6414f9 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs @@ -4,7 +4,7 @@ public interface ISubjectsRepository { public Task> GetReactionsAsync(string subjectId, Guid authorId); public Task SetReactionsAsync(string subjectId, Guid authorId, ISet reactions); - public Task UpdateReactionCountersAsync(string subjectId, IEnumerable reactionsToAdd, IEnumerable reactionsToRemove); + public Task UpdateReactionCountersAsync(string subjectId, IDictionary reactionsDiff); public Task IncludeCommentInMetadataAsync(string discussionId, Guid authorId); public Task ExcludeCommentFromMetadataAsync(string discussionId); diff --git a/src/CrowdParlay.Social.Domain/Entities/Comment.cs b/src/CrowdParlay.Social.Domain/Entities/Comment.cs index b28710c..06a652c 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Comment.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Comment.cs @@ -2,7 +2,6 @@ namespace CrowdParlay.Social.Domain.Entities; - [DebuggerDisplay("{Id} by {AuthorId} in reply to {SubjectId}")] public class Comment { @@ -13,6 +12,12 @@ public class Comment public required DateTimeOffset CreatedAt { get; set; } public required int CommentCount { get; set; } public required IList LastCommentsAuthorIds { get; set; } - public required IDictionary ReactionCounters { get; set; } public required IList ViewerReactions { get; set; } + + private IDictionary _reactionCounters = null!; + public required IDictionary ReactionCounters + { + get => _reactionCounters; + set => _reactionCounters = value.Where(kv => kv.Value > 0).ToDictionary(); + } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs index 9ec1e2b..b80510c 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs @@ -2,7 +2,6 @@ namespace CrowdParlay.Social.Domain.Entities; - [DebuggerDisplay("{Id} by {AuthorId}")] public class Discussion { @@ -13,6 +12,12 @@ public class Discussion public required DateTimeOffset CreatedAt { get; set; } public required int CommentCount { get; set; } public required IList LastCommentsAuthorIds { get; set; } - public required IDictionary ReactionCounters { get; set; } public required IList ViewerReactions { get; set; } + + private IDictionary _reactionCounters = null!; + public required IDictionary ReactionCounters + { + get => _reactionCounters; + set => _reactionCounters = value.Where(kv => kv.Value > 0).ToDictionary(); + } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs index 8610f21..4055dd0 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -160,8 +160,8 @@ public async Task> GetReactionsAsync(string commentId, Guid authorI public async Task SetReactionsAsync(string commentId, Guid authorId, ISet reactions) => await _subjectsRepository.SetReactionsAsync(commentId, authorId, reactions); - public async Task UpdateReactionCountersAsync(string commentId, IEnumerable reactionsToAdd, IEnumerable reactionsToRemove) => - await _subjectsRepository.UpdateReactionCountersAsync(commentId, reactionsToAdd, reactionsToRemove); + public async Task UpdateReactionCountersAsync(string commentId, IDictionary reactionsDiff) => + await _subjectsRepository.UpdateReactionCountersAsync(commentId, reactionsDiff); private static Expression> CreateCommentProjectionExpression(Guid? viewerId) => comment => new Comment { diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs index 2c7db99..eaaa098 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs @@ -105,8 +105,8 @@ public async Task> GetReactionsAsync(string discussionId, Guid auth public async Task SetReactionsAsync(string discussionId, Guid authorId, ISet reactions) => await _subjectsRepository.SetReactionsAsync(discussionId, authorId, reactions); - public async Task UpdateReactionCountersAsync(string subjectId, IEnumerable reactionsToAdd, IEnumerable reactionsToRemove) => - await _subjectsRepository.UpdateReactionCountersAsync(subjectId, reactionsToAdd, reactionsToRemove); + public async Task UpdateReactionCountersAsync(string subjectId, IDictionary reactionsDiff) => + await _subjectsRepository.UpdateReactionCountersAsync(subjectId, reactionsDiff); public async Task IncludeCommentInMetadataAsync(string discussionId, Guid authorId) => await _subjectsRepository.IncludeCommentInMetadataAsync(discussionId, authorId); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs index a0c425e..c797d3d 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs @@ -16,8 +16,8 @@ public async Task> GetReactionsAsync(string subjectId, Guid authorI var pipeline = _subjects .Find(session, subject => subject.Id == ObjectId.Parse(subjectId)) .Project(subject => subject.ReactionsByAuthorId.ContainsKey(authorId.ToString()) - ? subject.ReactionsByAuthorId[authorId.ToString()] - : new string[] { }); + ? subject.ReactionsByAuthorId[authorId.ToString()] + : new string[] { }); var reactions = await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); return reactions.ToHashSet(); @@ -38,16 +38,13 @@ public async Task SetReactionsAsync(string subjectId, Guid authorId, ISet reactionsToAdd, IEnumerable reactionsToRemove) + public async Task UpdateReactionCountersAsync(string subjectId, IDictionary reactionsDiff) { - var increments = reactionsToAdd.Select(reaction => - Builders.Update.Inc(subject => subject.ReactionCounters[reaction], 1)); - - var decrements = reactionsToRemove.Select(reaction => - Builders.Update.Inc(subject => subject.ReactionCounters[reaction], -1)); + var updates = reactionsDiff.Select(kv => + Builders.Update.Inc(subject => subject.ReactionCounters[kv.Key], kv.Value)); var filter = Builders.Filter.Eq(subject => subject.Id, ObjectId.Parse(subjectId)); - var update = Builders.Update.Combine(increments.Union(decrements)); + var update = Builders.Update.Combine(updates); var result = await _subjects.UpdateOneAsync(session, filter, update); if (result.MatchedCount == 0) @@ -76,7 +73,7 @@ await _subjects await _subjects.UpdateOneAsync(session, filter, update); } - + public async Task ExcludeCommentFromMetadataAsync(string subjectId) { var filter = Builders.Filter.Eq(subject => subject.Id, ObjectId.Parse(subjectId)); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs index 247917f..19ad88a 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs @@ -66,9 +66,7 @@ public async Task SetReactions_OverwritesExistingReactions() discussion.ViewerReactions.Should().BeEquivalentTo(woozyFace, redHeart); discussion.ReactionCounters.Should().BeEquivalentTo(new Dictionary { - { eggplant, 0 }, { woozyFace, 1 }, - { nailPolish, 0 }, { redHeart, 1 } }); }