diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml
index c6f8e3e..169adfd 100644
--- a/.github/workflows/openapi.yml
+++ b/.github/workflows/openapi.yml
@@ -23,15 +23,13 @@ jobs:
- name: ⚙️ Setup .NET
uses: actions/setup-dotnet@v1
with:
- dotnet-version: |
- 7.0.x
- 8.0.x
+ dotnet-version: 9.0.x
- name: ⚒ Build solution
run: dotnet build
- name: ⚙️ Restore .NET tools
run: dotnet tool restore
- name: 📝 Produce OpenAPI specification
- run: dotnet swagger tofile --output openapi.yaml --yaml ./src/CrowdParlay.Social.Api/bin/Debug/net8.0/CrowdParlay.Social.Api.dll v1
+ run: dotnet swagger tofile --output openapi.yaml --yaml ./src/CrowdParlay.Social.Api/bin/Debug/net9.0/CrowdParlay.Social.Api.dll v1
- name: 🔗 Upload OpenAPI specification as release asset
uses: actions/upload-release-asset@v1
env:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fc9ffbb..8601d4e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,9 +20,7 @@ jobs:
- name: ⚙️ Setup .NET
uses: actions/setup-dotnet@v3
with:
- dotnet-version: |
- 7.0.x
- 8.0.x
+ dotnet-version: 9.0.x
- name: 📥 Restore dependencies
run: dotnet restore
- name: ⚒ Build solution
@@ -35,4 +33,4 @@ jobs:
with:
name: .NET test results
path: "**/TestResults/*.trx"
- reporter: dotnet-trx
\ No newline at end of file
+ reporter: dotnet-trx
diff --git a/.gitmodules b/.gitmodules
index 0436067..1c59439 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,9 +1,3 @@
-[submodule "src/CrowdParlay.Social.Infrastructure.gRPC/Users"]
- path = src/CrowdParlay.Social.Infrastructure.gRPC/Users
- url = https://github.com/crowdparlay/users-proto
-[submodule "src/CrowdParlay.Social.Application/gRPC/Users"]
- path = src/CrowdParlay.Social.Application/gRPC/Users
- url = https://github.com/crowdparlay/users-proto
[submodule "src/CrowdParlay.Social.Infrastructure.Communication/gRPC/Users"]
path = src/CrowdParlay.Social.Infrastructure.Communication/gRPC/Users
url = https://github.com/crowdparlay/users-proto.git
diff --git a/Dockerfile b/Dockerfile
index 809b6f4..7439138 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,18 +1,12 @@
-FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /app
COPY /src .
RUN dotnet restore "CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj"
RUN dotnet publish "CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj" -c Release -o out
-FROM mcr.microsoft.com/dotnet/aspnet:8.0
+FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/out .
-COPY /neo4j/migrations ./neo4j/migrations
-RUN apt-get update && apt-get install -y curl unzip
-RUN curl -LO https://github.com/michael-simons/neo4j-migrations/releases/download/2.9.3/neo4j-migrations-2.9.3-linux-x86_64.zip
-RUN unzip -j neo4j-migrations-2.9.3-linux-x86_64.zip neo4j-migrations-2.9.3-linux-x86_64/bin/neo4j-migrations
-RUN rm neo4j-migrations-2.9.3-linux-x86_64.zip
-
-ENTRYPOINT ["sh", "-c", "./neo4j-migrations -a $NEO4J_URI -u $NEO4J_USERNAME -p $NEO4J_PASSWORD migrate && dotnet CrowdParlay.Social.Api.dll"]
+ENTRYPOINT ["sh", "-c", "dotnet CrowdParlay.Social.Api.dll"]
diff --git a/README.md b/README.md
index 7b663ed..8f7daf7 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,10 @@
# Crowd Parlay's *social* microservice [](https://github.com/crowdparlay/social/actions/workflows/test.yml)
-- **languages:** C# Cypher
-- **technologies:** .NET 8 ASP.NET Core RabbitMQ Neo4j SSE OpenAPI
-- **dependencies:** MassTransit SignalR MediatR FluentValidation Mapster Swashbuckle Testcontainers
+
+A high-performance, cloud-native microservice enabling discussions, threaded comments, emoji/text reactions, and real-time event publishing via message broker. Designed with a clean onion architecture and test-driven development, it integrates seamlessly with other services through resilient, cache-optimized API calls. Built on a NoSQL store with embedded documents and strategic indexing for fast reads. Basic telemetry and observability are integrated, with support for future expansion. Fully automated CI/CD pipelines enable one-click production deployments.
+
+- **Stack:** C# 13 .NET 9 ASP.NET Core MongoDb.Driver MassTransit OpenTelemetry SignalR FluentValidation Mapster Swashbuckle/OpenAPI Testcontainers
+- **Environment:** MongoDB Redis RabbitMQ Elastic APM [crowdparlay/users](https://github.com/crowdparlay/users)
+- **Adopted but retired:** Neo4j MediatR
+
+> [!NOTE]
+> This Git repository contains Submodules. Don’t forget to clone with `--recurse-submodules`
diff --git a/neo4j/migrations/V010__Add_indexes.cypher b/neo4j/migrations/V010__Add_indexes.cypher
deleted file mode 100644
index e7d19b4..0000000
--- a/neo4j/migrations/V010__Add_indexes.cypher
+++ /dev/null
@@ -1,3 +0,0 @@
-CREATE INDEX author_id_idx FOR (author:Author) ON (author.Id);
-CREATE INDEX discussion_id_idx FOR (discussion:Discussion) ON (discussion.Id);
-CREATE INDEX comment_id_idx FOR (comment:Comment) ON (comment.Id);
diff --git a/neo4j/migrations/V020__Remove_excessive_author_properties.cypher b/neo4j/migrations/V020__Remove_excessive_author_properties.cypher
deleted file mode 100644
index 9dfbe5f..0000000
--- a/neo4j/migrations/V020__Remove_excessive_author_properties.cypher
+++ /dev/null
@@ -1,2 +0,0 @@
-MATCH (author:Author)
-REMOVE author.Username, author.DisplayName, author.AvatarUrl;
diff --git a/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs b/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs
deleted file mode 100644
index a22f1dc..0000000
--- a/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CrowdParlay.Communication;
-using CrowdParlay.Social.Domain.Abstractions;
-using MassTransit;
-
-namespace CrowdParlay.Social.Api.Consumers;
-
-// ReSharper disable once ClassNeverInstantiated.Global
-public class UserEventConsumer(IAuthorsRepository authors) : IConsumer
-{
- public async Task Consume(ConsumeContext context) =>
- await authors.EnsureCreatedAsync(Guid.Parse(context.Message.UserId));
-}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj b/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj
index ea980ce..5d39314 100644
--- a/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj
+++ b/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj
@@ -1,7 +1,7 @@
- net8.0
+ net9.0
enable
enable
Linux
@@ -10,19 +10,19 @@
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
+
+
diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs
index dd7545b..b7898fc 100644
--- a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs
+++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs
@@ -1,5 +1,4 @@
using CrowdParlay.Communication;
-using CrowdParlay.Social.Api.Consumers;
using CrowdParlay.Social.Api.Services;
using MassTransit;
@@ -21,7 +20,6 @@ public static IServiceCollection AddApi(this IServiceCollection services, IConfi
return services.AddMassTransit(bus =>
{
- bus.AddConsumersFromNamespaceContaining();
bus.UsingRabbitMq((context, configurator) =>
{
var amqpServerUrl =
diff --git a/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs b/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs
index 0a6f167..4f75391 100644
--- a/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs
+++ b/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs
@@ -19,25 +19,23 @@ public override async Task OnDisconnectedAsync(Exception? exception)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupNames.NewCommentInDiscussion(discussionId));
}
- private Guid GetDiscussionIdFromQuery() =>
- Guid.TryParse(GetSingleQueryParameterValueFromQuery(DiscussionIdQueryParameterName), out var discussionId)
- ? discussionId
- : throw new ValidationException(DiscussionIdQueryParameterName, ["Must be a valid UUID."]);
-
- private string GetSingleQueryParameterValueFromQuery(string key)
+ private string GetDiscussionIdFromQuery()
{
var query =
Context.GetHttpContext()?.Request.Query
- ?? throw new InvalidOperationException("Cannot access request's query parameters, since HttpContext is null.");
+ ?? throw new InvalidOperationException(
+ "Cannot access request's query parameters, since HttpContext is null.");
- return query.TryGetValue(key, out var values)
- ? values.SingleOrDefault() ?? throw new ValidationException(key, ["Must have single value."])
- : throw new ValidationException(key, ["Query parameter is required."]);
+ return query.TryGetValue(DiscussionIdQueryParameterName, out var values)
+ ? values.SingleOrDefault() ??
+ throw new ValidationException(DiscussionIdQueryParameterName, ["Must have single value."])
+ : throw new ValidationException(DiscussionIdQueryParameterName, ["Query parameter is required."]);
}
public static class GroupNames
{
- public static string NewCommentInDiscussion(Guid discussionId) => $"{Events.NewComment.ToString()}/{discussionId}";
+ public static string NewCommentInDiscussion(string discussionId) =>
+ $"{Events.NewComment.ToString()}/{discussionId}";
}
public enum Events
diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs
index 51507e1..c0c64d3 100644
--- a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs
+++ b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs
@@ -1,113 +1,87 @@
using System.Net.Mime;
using CrowdParlay.Social.Api.Extensions;
-using CrowdParlay.Social.Api.Hubs;
using CrowdParlay.Social.Application.Abstractions;
using CrowdParlay.Social.Application.DTOs;
using CrowdParlay.Social.Domain.DTOs;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
-using Microsoft.AspNetCore.SignalR;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace CrowdParlay.Social.Api.v1.Controllers;
[ApiController, ApiRoute("[controller]")]
-public class CommentsController(
- ICommentsService commentsService,
- IReactionsService reactionsService,
- IHubContext commentHub) : ControllerBase
+public class CommentsController(ICommentsService commentsService) : ControllerBase
{
///
- /// Returns comment with the specified ID.
+ /// Retrieves a comment by its unique identifier.
///
- [HttpGet("{commentId:guid}")]
+ /// The unique identifier of the comment to retrieve.
+ /// The requested comment details.
+ [HttpGet("{commentId}")]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
- public async Task GetById([FromRoute] Guid commentId) =>
+ public async Task GetById([FromRoute] string commentId) =>
await commentsService.GetByIdAsync(commentId, User.GetUserId());
-
- ///
- /// Get comments by filters.
- ///
- [HttpGet]
- [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
- [ProducesResponseType>(Status200OK)]
- [ProducesResponseType(Status400BadRequest)]
- [ProducesResponseType(Status500InternalServerError)]
- public async Task> Search(
- [FromQuery] Guid? discussionId,
- [FromQuery] Guid? authorId,
- [FromQuery, BindRequired] int offset,
- [FromQuery, BindRequired] int count) =>
- await commentsService.SearchAsync(discussionId, authorId, User.GetUserId(), offset, count);
-
- ///
- /// Creates a top-level comment in discussion.
- ///
- [HttpPost, Authorize]
- [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
- [ProducesResponseType(Status201Created)]
- [ProducesResponseType(Status400BadRequest)]
- [ProducesResponseType(Status403Forbidden)]
- [ProducesResponseType(Status500InternalServerError)]
- public async Task> Create([FromBody] CommentRequest request)
- {
- var response = await commentsService.CreateAsync(User.GetRequiredUserId(), request.DiscussionId, request.Content);
-
- _ = commentHub.Clients
- .Group(CommentsHub.GroupNames.NewCommentInDiscussion(request.DiscussionId))
- .SendCoreAsync(CommentsHub.Events.NewComment.ToString(), [response]);
-
- return CreatedAtAction(nameof(GetById), new { commentId = response.Id }, response);
- }
-
+
///
- /// Get replies to the comment with the specified ID.
+ /// Retrieves replies to a specified comment with configurable tree traversal and pagination.
///
- [HttpGet("{parentCommentId:guid}/replies")]
+ /// The unique identifier of the parent comment.
+ /// When true, returns all nested replies in a flat structure; otherwise returns direct children only.
+ /// The number of items to skip before starting to return results (pagination offset).
+ /// The maximum number of items to return (pagination limit).
+ /// A paginated list of comment replies.
+ [HttpGet("{commentId}/replies")]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType>(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
public async Task> GetReplies(
- [FromRoute] Guid parentCommentId,
+ [FromRoute] string commentId,
+ [FromQuery, BindRequired] bool flatten,
[FromQuery, BindRequired] int offset,
[FromQuery, BindRequired] int count) =>
- await commentsService.GetRepliesToCommentAsync(parentCommentId, User.GetUserId(), offset, count);
-
+ await commentsService.GetRepliesAsync(commentId, flatten, User.GetUserId(), offset, count);
+
///
- /// Creates a reply to the comment with the specified ID.
+ /// Creates a new reply to a specified comment. Requires authenticated user.
///
- [HttpPost("{parentCommentId:guid}/replies"), Authorize]
+ /// The unique identifier of the comment being replied to.
+ /// The content of the reply.
+ /// The newly created reply comment details.
+ [HttpPost("{commentId}/replies"), Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status201Created)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status403Forbidden)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
- public async Task> Reply([FromRoute] Guid parentCommentId, [FromBody] ReplyRequest request)
+ public async Task> Reply([FromRoute] string commentId, [FromBody] ReplyRequest request)
{
- var response = await commentsService.ReplyToCommentAsync(User.GetRequiredUserId(), parentCommentId, request.Content);
+ var response = await commentsService.ReplyToCommentAsync(commentId, User.GetRequiredUserId(), request.Content);
return CreatedAtAction(nameof(GetById), new { commentId = response.Id }, response);
}
///
- /// Sets reactions to a comment.
+ /// Updates reactions on a specified comment. Requires authenticated user.
///
- [HttpPost("{commentId:guid}/reactions"), Authorize]
+ /// The unique identifier of the comment to react to.
+ /// The set of reaction identifiers to apply.
+ /// No content response upon successful update.
+ [HttpPost("{commentId}/reactions"), Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status403Forbidden)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
- public async Task React([FromRoute] Guid commentId, [FromBody] ISet reactions)
+ public async Task React([FromRoute] string commentId, [FromBody] ISet reactions)
{
- await reactionsService.SetAsync(commentId, User.GetRequiredUserId(), reactions);
+ await commentsService.SetReactionsAsync(commentId, User.GetRequiredUserId(), reactions);
return NoContent();
}
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs
index d20e421..625c44c 100644
--- a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs
+++ b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs
@@ -1,32 +1,43 @@
using System.Net.Mime;
using CrowdParlay.Social.Api.Extensions;
+using CrowdParlay.Social.Api.Hubs;
using CrowdParlay.Social.Application.Abstractions;
using CrowdParlay.Social.Application.DTOs;
using CrowdParlay.Social.Domain.DTOs;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.SignalR;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace CrowdParlay.Social.Api.v1.Controllers;
[ApiController, ApiRoute("[controller]")]
-public class DiscussionsController(IDiscussionsService discussionsService, IReactionsService reactionsService) : ControllerBase
+public class DiscussionsController(
+ IDiscussionsService discussionsService,
+ ICommentsService commentsService,
+ IHubContext commentHub) : ControllerBase
{
///
- /// Returns discussion with the specified ID.
+ /// Retrieves a discussion by its unique identifier.
///
- [HttpGet("{discussionId:guid}")]
+ /// The unique identifier of the discussion to retrieve.
+ /// The requested discussion details.
+ [HttpGet("{discussionId}")]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
- public async Task GetById([FromRoute] Guid discussionId) =>
+ public async Task GetById([FromRoute] string discussionId) =>
await discussionsService.GetByIdAsync(discussionId, User.GetUserId());
///
- /// Returns all discussions created by author with the specified ID.
+ /// Searches discussions filtered by author identifier with pagination.
///
+ /// Optional author identifier to filter discussions (when null, returns all discussions).
+ /// The number of items to skip before starting to return results (pagination offset).
+ /// The maximum number of items to return (pagination limit).
+ /// A paginated list of matching discussions.
[HttpGet]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType>(Status200OK)]
@@ -38,8 +49,49 @@ public async Task> Search(
await discussionsService.SearchAsync(authorId, User.GetUserId(), offset, count);
///
- /// Creates a discussion.
+ /// Retrieves top-level comments for a specified discussion with pagination.
///
+ /// The unique identifier of the discussion.
+ /// The number of items to skip before starting to return results (pagination offset).
+ /// The maximum number of items to return (pagination limit).
+ /// A paginated list of top-level comments in the discussion.
+ [HttpGet("{discussionId}/comments")]
+ [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
+ [ProducesResponseType>(Status200OK)]
+ [ProducesResponseType(Status500InternalServerError)]
+ public async Task> GetComments(
+ [FromRoute] string discussionId,
+ [FromQuery, BindRequired] int offset,
+ [FromQuery, BindRequired] int count) =>
+ await commentsService.GetRepliesAsync(discussionId, false, User.GetUserId(), offset, count);
+
+ ///
+ /// Creates a new top-level comment in a discussion. Requires authenticated user and triggers real-time notifications.
+ ///
+ /// The comment content and target discussion identifier.
+ /// The newly created top-level comment details.
+ [HttpPost("{discussionId}/comments"), Authorize]
+ [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
+ [ProducesResponseType(Status201Created)]
+ [ProducesResponseType(Status400BadRequest)]
+ [ProducesResponseType(Status403Forbidden)]
+ [ProducesResponseType(Status500InternalServerError)]
+ public async Task> Reply([FromBody] CommentRequest request)
+ {
+ var response = await commentsService.ReplyToDiscussionAsync(request.DiscussionId, User.GetRequiredUserId(), request.Content);
+
+ _ = commentHub.Clients
+ .Group(CommentsHub.GroupNames.NewCommentInDiscussion(request.DiscussionId))
+ .SendCoreAsync(CommentsHub.Events.NewComment.ToString(), [response]);
+
+ return CreatedAtAction(nameof(CommentsController.GetById), "Comments", new { commentId = response.Id }, response);
+ }
+
+ ///
+ /// Creates a new discussion. Requires authenticated user.
+ ///
+ /// The discussion title and content.
+ /// The newly created discussion details.
[HttpPost, Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status201Created)]
@@ -47,35 +99,42 @@ public async Task> Search(
[ProducesResponseType(Status500InternalServerError)]
public async Task> Create([FromBody] DiscussionRequest request)
{
- var response = await discussionsService.CreateAsync(User.GetRequiredUserId(), request.Title, request.Description);
+ var response = await discussionsService.CreateAsync(User.GetRequiredUserId(), request.Title, request.Content);
return CreatedAtAction(nameof(GetById), new { DiscussionId = response.Id }, response);
}
///
- /// Modifies a discussion.
+ /// Modifies an existing discussion. Requires authenticated user and ownership permissions.
///
- [HttpPatch("{discussionId:guid}"), Authorize]
+ /// The unique identifier of the discussion to update.
+ /// The updated discussion data.
+ /// The modified discussion details.
+ [HttpPatch("{discussionId}"), Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status403Forbidden)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
- public async Task Update([FromRoute] Guid discussionId, [FromBody] UpdateDiscussionRequest request) =>
+ public async Task Update([FromRoute] string discussionId,
+ [FromBody] UpdateDiscussionRequest request) =>
await discussionsService.UpdateAsync(discussionId, User.GetRequiredUserId(), request);
///
- /// Sets reactions to a discussion.
+ /// Updates reactions on a specified discussion. Requires authenticated user.
///
- [HttpPost("{discussionId:guid}/reactions"), Authorize]
+ /// The unique identifier of the discussion to react to.
+ /// The set of reaction identifiers to apply.
+ /// No content response upon successful update.
+ [HttpPost("{discussionId}/reactions"), Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status403Forbidden)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
- public async Task React([FromRoute] Guid discussionId, [FromBody] ISet reactions)
+ public async Task React([FromRoute] string discussionId, [FromBody] ISet reactions)
{
- await reactionsService.SetAsync(discussionId, User.GetRequiredUserId(), reactions);
+ await discussionsService.SetReactionsAsync(discussionId, User.GetRequiredUserId(), reactions);
return NoContent();
}
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs
index dacae67..ec2fdbf 100644
--- a/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs
+++ b/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs
@@ -7,6 +7,10 @@ namespace CrowdParlay.Social.Api.v1.Controllers;
[ApiController, ApiRoute("[controller]")]
public class LookupController : ControllerBase
{
+ ///
+ /// Retrieves all available reactions.
+ ///
+ /// The complete set of allowed reaction values.
[HttpGet("reactions"), Produces(MediaTypeNames.Application.Json)]
public IReadOnlySet GetAvailableReactions() => Reaction.AllowedValues;
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs b/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs
index 0cc497a..d607c6e 100644
--- a/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs
+++ b/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs
@@ -3,12 +3,11 @@
namespace CrowdParlay.Social.Application.Abstractions;
-public interface ICommentsService
+public interface ICommentsService : ISubjectsService
{
- public Task GetByIdAsync(Guid commentId, Guid? viewerId);
- public Task> SearchAsync(Guid? discussionId, Guid? authorId, Guid? viewerId, int offset, int count);
- public Task CreateAsync(Guid authorId, Guid discussionId, string content);
- public Task> GetRepliesToCommentAsync(Guid parentCommentId, Guid? viewerId, int offset, int count);
- public Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content);
- public Task DeleteAsync(Guid id);
+ public Task GetByIdAsync(string commentId, Guid? viewerId);
+ public Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count);
+ public Task ReplyToDiscussionAsync(string discussionId, Guid authorId, string content);
+ public Task ReplyToCommentAsync(string commentId, Guid authorId, string content);
+ public Task DeleteAsync(string commentId);
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs b/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs
index 15e9199..ce0b662 100644
--- a/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs
+++ b/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs
@@ -3,10 +3,10 @@
namespace CrowdParlay.Social.Application.Abstractions;
-public interface IDiscussionsService
+public interface IDiscussionsService : ISubjectsService
{
- public Task GetByIdAsync(Guid discussionId, Guid? viewerId);
+ public Task GetByIdAsync(string discussionId, Guid? viewerId);
public Task> SearchAsync(Guid? authorId, Guid? viewerId, int offset, int count);
- public Task CreateAsync(Guid authorId, string title, string description);
- public Task UpdateAsync(Guid discussionId, Guid viewerId, UpdateDiscussionRequest request);
+ public Task CreateAsync(Guid authorId, string title, string content);
+ public Task UpdateAsync(string discussionId, Guid viewerId, UpdateDiscussionRequest request);
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/Abstractions/IReactionsService.cs b/src/CrowdParlay.Social.Application/Abstractions/IReactionsService.cs
deleted file mode 100644
index d9bcc40..0000000
--- a/src/CrowdParlay.Social.Application/Abstractions/IReactionsService.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace CrowdParlay.Social.Application.Abstractions;
-
-public interface IReactionsService
-{
- public Task> GetAsync(Guid subjectId, Guid viewerId);
- public Task SetAsync(Guid subjectId, Guid viewerId, ISet reactions);
-}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/Abstractions/ISubjectsService.cs b/src/CrowdParlay.Social.Application/Abstractions/ISubjectsService.cs
new file mode 100644
index 0000000..426fd70
--- /dev/null
+++ b/src/CrowdParlay.Social.Application/Abstractions/ISubjectsService.cs
@@ -0,0 +1,7 @@
+namespace CrowdParlay.Social.Application.Abstractions;
+
+public interface ISubjectsService
+{
+ public Task> GetReactionsAsync(string subjectId, Guid authorId);
+ public Task SetReactionsAsync(string subjectId, Guid authorId, ISet reactions);
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/ConfigureServices.cs b/src/CrowdParlay.Social.Application/ConfigureServices.cs
index cc82b0f..f7497e2 100644
--- a/src/CrowdParlay.Social.Application/ConfigureServices.cs
+++ b/src/CrowdParlay.Social.Application/ConfigureServices.cs
@@ -11,6 +11,5 @@ public static class ConfigureServicesExtensions
public static IServiceCollection AddApplication(this IServiceCollection services) => services
.AddScoped()
.AddScoped()
- .AddScoped()
.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly(), includeInternalTypes: true);
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj
index b7c722f..b08ee04 100644
--- a/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj
+++ b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj
@@ -1,23 +1,23 @@
- net8.0
+ net9.0
enable
enable
-
-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs b/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs
index c8526fa..141df88 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(Guid DiscussionId, string Content);
\ No newline at end of file
+public record CommentRequest(string DiscussionId, string Content);
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs b/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs
index 7824000..2eaa608 100644
--- a/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs
+++ b/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs
@@ -2,12 +2,12 @@ namespace CrowdParlay.Social.Application.DTOs;
public class CommentResponse
{
- public required Guid Id { get; set; }
+ public required string Id { get; set; }
public required string Content { get; set; }
public required AuthorResponse? Author { get; set; }
public required DateTimeOffset CreatedAt { get; set; }
- public required int ReplyCount { get; set; }
- public required IEnumerable LastRepliesAuthors { get; set; }
+ public required int CommentCount { get; set; }
+ public required IEnumerable LastCommentsAuthors { get; set; }
public required IDictionary ReactionCounters { get; set; }
public required ISet ViewerReactions { get; set; }
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/DTOs/DiscussionRequest.cs b/src/CrowdParlay.Social.Application/DTOs/DiscussionRequest.cs
index c1d2e7c..bab7f0f 100644
--- a/src/CrowdParlay.Social.Application/DTOs/DiscussionRequest.cs
+++ b/src/CrowdParlay.Social.Application/DTOs/DiscussionRequest.cs
@@ -1,3 +1,3 @@
namespace CrowdParlay.Social.Application.DTOs;
-public record DiscussionRequest(string Title, string Description);
\ No newline at end of file
+public record DiscussionRequest(string Title, string Content);
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs b/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs
index 2729b6c..087f3fa 100644
--- a/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs
+++ b/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs
@@ -2,9 +2,9 @@ namespace CrowdParlay.Social.Application.DTOs;
public class DiscussionResponse
{
- public required Guid Id { get; set; }
+ public required string Id { get; set; }
public required string Title { get; set; }
- public required string Description { get; set; }
+ public required string Content { get; set; }
public required AuthorResponse? Author { get; set; }
public required DateTimeOffset CreatedAt { get; set; }
public required int CommentCount { get; set; }
diff --git a/src/CrowdParlay.Social.Application/Services/CommentsService.cs b/src/CrowdParlay.Social.Application/Services/CommentsService.cs
index 834c608..1e18d94 100644
--- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs
+++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs
@@ -11,18 +11,21 @@ namespace CrowdParlay.Social.Application.Services;
public class CommentsService(
IUnitOfWorkFactory unitOfWorkFactory,
ICommentsRepository commentsRepository,
+ IDiscussionsRepository discussionsRepository,
IUsersService usersService)
: ICommentsService
{
- public async Task GetByIdAsync(Guid commentId, Guid? viewerId)
+ private readonly ISubjectsService _subjectsService = new SubjectsService(commentsRepository);
+
+ public async Task GetByIdAsync(string commentId, Guid? viewerId)
{
var comment = await commentsRepository.GetByIdAsync(commentId, viewerId);
return await EnrichAsync(comment);
}
- public async Task> SearchAsync(Guid? discussionId, Guid? authorId, Guid? viewerId, int offset, int count)
+ public async Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count)
{
- var page = await commentsRepository.SearchAsync(discussionId, authorId, viewerId, offset, count);
+ var page = await commentsRepository.GetRepliesAsync(subjectId, flatten, viewerId, offset, count);
return new Page
{
TotalCount = page.TotalCount,
@@ -30,67 +33,87 @@ public async Task> SearchAsync(Guid? discussionId, Guid? a
};
}
- public async Task CreateAsync(Guid authorId, Guid discussionId, string content)
+ public async Task ReplyToDiscussionAsync(string discussionId, Guid authorId, string content)
+ {
+ await discussionsRepository.GetByIdAsync(discussionId, null);
+ return await CreateAsync(discussionId, authorId, content);
+ }
+
+ public async Task ReplyToCommentAsync(string commentId, Guid authorId, string content)
+ {
+ await commentsRepository.GetByIdAsync(commentId, null);
+ return await CreateAsync(commentId, authorId, content);
+ }
+
+ private async Task CreateAsync(string subjectId, Guid authorId, string content)
{
- var source = new ActivitySource("test source");
+ var source = new ActivitySource(nameof(CommentsService));
using var activity = source.CreateActivity("Create comment", ActivityKind.Server);
Comment comment;
- await using (var unitOfWork = await unitOfWorkFactory.CreateAsync())
+ using (var unitOfWork = await unitOfWorkFactory.CreateAsync())
{
- var commentId = await unitOfWork.CommentsRepository.CreateAsync(authorId, discussionId, content);
+ var commentId = await unitOfWork.CommentsRepository.CreateAsync(subjectId, authorId, content);
comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId);
await unitOfWork.CommitAsync();
}
- // TODO: notify clients via SignalR
+ using (source.CreateActivity("Update dependant metadata", ActivityKind.Server))
+ {
+ var ancestors = await commentsRepository.GetAncestorsAsync(comment.Id, null);
+ await commentsRepository.IncludeCommentInAncestorsMetadataAsync(ancestors, authorId);
+
+ var discussionId = ancestors.LastOrDefault()?.SubjectId ?? subjectId;
+ await discussionsRepository.IncludeCommentInMetadataAsync(discussionId, authorId);
+ }
return await EnrichAsync(comment);
}
- public async Task> GetRepliesToCommentAsync(Guid parentCommentId, Guid? viewerId, int offset, int count)
- {
- var page = await commentsRepository.SearchAsync(parentCommentId, authorId: null, viewerId, offset, count);
- return new Page
- {
- TotalCount = page.TotalCount,
- Items = await EnrichAsync(page.Items.ToArray())
- };
- }
+ public async Task> GetReactionsAsync(string commentId, Guid authorId) =>
+ await _subjectsService.GetReactionsAsync(commentId, authorId);
+
+ public async Task SetReactionsAsync(string commentId, Guid authorId, ISet reactions) =>
+ await _subjectsService.SetReactionsAsync(commentId, authorId, reactions);
- public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content)
+ public async Task DeleteAsync(string commentId)
{
- Comment comment;
- await using (var unitOfWork = await unitOfWorkFactory.CreateAsync())
+ var source = new ActivitySource(nameof(CommentsService));
+ using var activity = source.CreateActivity("Delete comment", ActivityKind.Server);
+
+ var comment = await commentsRepository.GetByIdAsync(commentId, null);
+ using (source.CreateActivity("Update dependant metadata", ActivityKind.Server))
{
- var commentId = await unitOfWork.CommentsRepository.ReplyToCommentAsync(authorId, parentCommentId, content);
- comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId);
- await unitOfWork.CommitAsync();
+ var ancestors = await commentsRepository.GetAncestorsAsync(commentId, null);
+ await commentsRepository.ExcludeCommentFromAncestorsMetadataAsync(ancestors);
+
+ var discussionId = ancestors.LastOrDefault()?.SubjectId ?? comment.SubjectId;
+ await discussionsRepository.ExcludeCommentFromMetadataAsync(discussionId);
}
- return await EnrichAsync(comment);
+ await commentsRepository.DeleteAsync(commentId);
}
- public async Task DeleteAsync(Guid id) => await commentsRepository.DeleteAsync(id);
-
private async Task EnrichAsync(Comment comment) => (await EnrichAsync([comment])).First();
private async Task> EnrichAsync(IReadOnlyList comments)
{
- var authorIds = comments.SelectMany(comment => comment.LastRepliesAuthorIds.Append(comment.AuthorId)).ToHashSet();
- var authorsById = await usersService.GetUsersAsync(authorIds);
+ var authorIds = comments
+ .SelectMany(comment => comment.LastCommentsAuthorIds.Append(comment.AuthorId))
+ .ToHashSet();
+ var authorsById = await usersService.GetUsersAsync(authorIds);
return comments.Select(comment => new CommentResponse
{
Id = comment.Id,
Content = comment.Content,
Author = authorsById[comment.AuthorId].Adapt(),
CreatedAt = comment.CreatedAt,
- ReplyCount = comment.ReplyCount,
- LastRepliesAuthors = comment.LastRepliesAuthorIds
+ CommentCount = comment.CommentCount,
+ LastCommentsAuthors = comment.LastCommentsAuthorIds
.Select(replyAuthorId => authorsById[replyAuthorId].Adapt()),
ReactionCounters = comment.ReactionCounters,
- ViewerReactions = comment.ViewerReactions
+ ViewerReactions = comment.ViewerReactions.ToHashSet()
});
}
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs
index 5942440..54a5dd9 100644
--- a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs
+++ b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs
@@ -14,7 +14,9 @@ public class DiscussionsService(
IUsersService usersService)
: IDiscussionsService
{
- public async Task GetByIdAsync(Guid discussionId, Guid? viewerId)
+ private readonly ISubjectsService _subjectsService = new SubjectsService(discussionsRepository);
+
+ public async Task GetByIdAsync(string discussionId, Guid? viewerId)
{
var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId);
return await EnrichAsync(discussion);
@@ -30,12 +32,13 @@ public async Task> SearchAsync(Guid? authorId, Guid? vi
};
}
- public async Task CreateAsync(Guid authorId, string title, string description)
+ public async Task CreateAsync(Guid authorId, string title, string content)
{
Discussion discussion;
- await using (var unitOfWork = await unitOfWorkFactory.CreateAsync())
+
+ using (var unitOfWork = await unitOfWorkFactory.CreateAsync())
{
- var discussionId = await unitOfWork.DiscussionsRepository.CreateAsync(authorId, title, description);
+ var discussionId = await discussionsRepository.CreateAsync(authorId, title, content);
discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, authorId);
await unitOfWork.CommitAsync();
}
@@ -43,43 +46,45 @@ public async Task CreateAsync(Guid authorId, string title, s
return await EnrichAsync(discussion);
}
- public async Task UpdateAsync(Guid discussionId, Guid viewerId, UpdateDiscussionRequest request)
+ public async Task UpdateAsync(string discussionId, Guid viewerId,
+ UpdateDiscussionRequest request)
{
- Discussion discussion;
- await using (var unitOfWork = await unitOfWorkFactory.CreateAsync())
- {
- discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, viewerId);
- if (discussion.AuthorId != viewerId)
- throw new ForbiddenException("Cannot modify a discussion created by another user.");
-
- await unitOfWork.DiscussionsRepository.UpdateAsync(discussionId, request.Title, request.Description);
- discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, viewerId);
-
- await unitOfWork.CommitAsync();
- }
+ var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId);
+ if (discussion.AuthorId != viewerId)
+ throw new ForbiddenException("Cannot modify a discussion created by another user.");
+ await discussionsRepository.UpdateAsync(discussionId, request.Title, request.Description);
+ discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId);
return await EnrichAsync(discussion);
}
+ public async Task> GetReactionsAsync(string discussionId, Guid authorId) =>
+ await _subjectsService.GetReactionsAsync(discussionId, authorId);
+
+ public async Task SetReactionsAsync(string discussionId, Guid authorId, ISet reactions) =>
+ await _subjectsService.SetReactionsAsync(discussionId, authorId, reactions);
+
private async Task EnrichAsync(Discussion discussion) => (await EnrichAsync([discussion])).First();
private async Task> EnrichAsync(IReadOnlyList discussions)
{
- var authorIds = discussions.SelectMany(discussion => discussion.LastCommentsAuthorIds.Append(discussion.AuthorId)).ToHashSet();
+ var authorIds = discussions
+ .SelectMany(discussion => discussion.LastCommentsAuthorIds.Append(discussion.AuthorId))
+ .ToHashSet();
+
var authorsById = await usersService.GetUsersAsync(authorIds);
-
return discussions.Select(discussion => new DiscussionResponse
{
Id = discussion.Id,
Title = discussion.Title,
- Description = discussion.Description,
+ Content = discussion.Content,
Author = authorsById[discussion.AuthorId].Adapt(),
CreatedAt = discussion.CreatedAt,
CommentCount = discussion.CommentCount,
LastCommentsAuthors = discussion.LastCommentsAuthorIds
.Select(replyAuthorId => authorsById[replyAuthorId].Adapt()),
ReactionCounters = discussion.ReactionCounters,
- ViewerReactions = discussion.ViewerReactions
+ ViewerReactions = discussion.ViewerReactions.ToHashSet()
});
}
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/Services/ReactionsService.cs b/src/CrowdParlay.Social.Application/Services/ReactionsService.cs
deleted file mode 100644
index f64cc63..0000000
--- a/src/CrowdParlay.Social.Application/Services/ReactionsService.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using CrowdParlay.Social.Application.Abstractions;
-using CrowdParlay.Social.Application.Exceptions;
-using CrowdParlay.Social.Domain.Abstractions;
-using CrowdParlay.Social.Domain.ValueObjects;
-
-namespace CrowdParlay.Social.Application.Services;
-
-public class ReactionsService(IUnitOfWorkFactory unitOfWorkFactory, IReactionsRepository reactionsRepository) : IReactionsService
-{
- public async Task> GetAsync(Guid subjectId, Guid viewerId) =>
- await reactionsRepository.GetAsync(subjectId, viewerId);
-
- public async Task SetAsync(Guid subjectId, Guid viewerId, ISet reactions)
- {
- await using var unitOfWork = await unitOfWorkFactory.CreateAsync();
-
- var alreadyExistingSubjectReactions = await unitOfWork.ReactionsRepository.GetAsync(subjectId);
- var allowedReactions = Reaction.AllowedValues.Union(alreadyExistingSubjectReactions).ToHashSet();
-
- if (!reactions.IsSubsetOf(allowedReactions))
- throw new ForbiddenException("Such reaction set is not allowed.");
-
- await unitOfWork.ReactionsRepository.SetAsync(subjectId, viewerId, reactions);
- await unitOfWork.CommitAsync();
- }
-}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Application/Services/SubjectsService.cs b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs
new file mode 100644
index 0000000..7022d98
--- /dev/null
+++ b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs
@@ -0,0 +1,27 @@
+using CrowdParlay.Social.Application.Abstractions;
+using CrowdParlay.Social.Application.Exceptions;
+using CrowdParlay.Social.Domain.Abstractions;
+using CrowdParlay.Social.Domain.ValueObjects;
+
+namespace CrowdParlay.Social.Application.Services;
+
+public class SubjectsService(ISubjectsRepository subjectsRepository) : ISubjectsService
+{
+ public async Task> GetReactionsAsync(string subjectId, Guid authorId) =>
+ await subjectsRepository.GetReactionsAsync(subjectId, authorId);
+
+ public async Task SetReactionsAsync(string subjectId, Guid authorId, ISet newReactions)
+ {
+ var oldReactions = await subjectsRepository.GetReactionsAsync(subjectId, authorId);
+ var allowedReactions = Reaction.AllowedValues.Union(oldReactions).ToArray();
+
+ if (!newReactions.IsSubsetOf(allowedReactions))
+ throw new ForbiddenException("Such reaction set is not allowed.");
+
+ await subjectsRepository.SetReactionsAsync(subjectId, authorId, newReactions);
+
+ var reactionsToAdd = newReactions.Except(oldReactions).ToArray();
+ var reactionsToRemove = oldReactions.Except(newReactions).ToArray();
+ await subjectsRepository.UpdateReactionCountersAsync(subjectId, reactionsToAdd, reactionsToRemove);
+ }
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IAuthorsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IAuthorsRepository.cs
deleted file mode 100644
index af2c518..0000000
--- a/src/CrowdParlay.Social.Domain/Abstractions/IAuthorsRepository.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace CrowdParlay.Social.Domain.Abstractions;
-
-public interface IAuthorsRepository
-{
- public Task EnsureCreatedAsync(Guid authorId);
-}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs
index b79368b..8a082f7 100644
--- a/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs
+++ b/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs
@@ -3,11 +3,13 @@
namespace CrowdParlay.Social.Domain.Abstractions;
-public interface ICommentsRepository
+public interface ICommentsRepository : ISubjectsRepository
{
- public Task GetByIdAsync(Guid commentId, Guid? viewerId);
- public Task> SearchAsync(Guid? subjectId, Guid? authorId, Guid? viewerId, int offset, int count);
- public Task CreateAsync(Guid authorId, Guid discussionId, string content);
- public Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content);
- public Task DeleteAsync(Guid commentId);
+ public Task GetByIdAsync(string commentId, Guid? viewerId);
+ public Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count);
+ public Task CreateAsync(string? subjectId, Guid authorId, string content);
+ public Task> GetAncestorsAsync(string commentId, Guid? viewerId);
+ public Task IncludeCommentInAncestorsMetadataAsync(IEnumerable ancestors, Guid authorId);
+ public Task ExcludeCommentFromAncestorsMetadataAsync(IEnumerable ancestors);
+ public Task DeleteAsync(string commentId);
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs
index 00811f5..cd26d9e 100644
--- a/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs
+++ b/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs
@@ -3,10 +3,10 @@
namespace CrowdParlay.Social.Domain.Abstractions;
-public interface IDiscussionsRepository
+public interface IDiscussionsRepository : ISubjectsRepository
{
- public Task GetByIdAsync(Guid discussionId, Guid? viewerId);
+ public Task GetByIdAsync(string discussionId, Guid? viewerId);
public Task> SearchAsync(Guid? authorId, Guid? viewerId, int offset, int count);
- public Task CreateAsync(Guid authorId, string title, string description);
- public Task UpdateAsync(Guid discussionId, string? title = null, string? description = null);
+ public Task CreateAsync(Guid authorId, string title, string content);
+ public Task UpdateAsync(string discussionId, string? title = null, string? content = null);
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs
deleted file mode 100644
index 0bbe9fd..0000000
--- a/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace CrowdParlay.Social.Domain.Abstractions;
-
-public interface IReactionsRepository
-{
- public Task> GetAsync(Guid subjectId);
- public Task> GetAsync(Guid subjectId, Guid viewerId);
- public Task SetAsync(Guid subjectId, Guid viewerId, ISet reactions);
-}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs
new file mode 100644
index 0000000..4a9e5e6
--- /dev/null
+++ b/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs
@@ -0,0 +1,11 @@
+namespace CrowdParlay.Social.Domain.Abstractions;
+
+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 IncludeCommentInMetadataAsync(string discussionId, Guid authorId);
+ public Task ExcludeCommentFromMetadataAsync(string discussionId);
+
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs
index 44b524c..7a7139b 100644
--- a/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs
+++ b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs
@@ -1,10 +1,9 @@
namespace CrowdParlay.Social.Domain.Abstractions;
-public interface IUnitOfWork : IAsyncDisposable
+public interface IUnitOfWork : IDisposable
{
public IDiscussionsRepository DiscussionsRepository { get; }
public ICommentsRepository CommentsRepository { get; }
- public IReactionsRepository ReactionsRepository { get; }
Task CommitAsync();
Task RollbackAsync();
diff --git a/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj b/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj
index b580e96..961de39 100644
--- a/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj
+++ b/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj
@@ -1,7 +1,7 @@
- net8.0
+ net9.0
enable
enable
diff --git a/src/CrowdParlay.Social.Domain/Entities/Comment.cs b/src/CrowdParlay.Social.Domain/Entities/Comment.cs
index f122d33..b28710c 100644
--- a/src/CrowdParlay.Social.Domain/Entities/Comment.cs
+++ b/src/CrowdParlay.Social.Domain/Entities/Comment.cs
@@ -1,13 +1,18 @@
+using System.Diagnostics;
+
namespace CrowdParlay.Social.Domain.Entities;
+
+[DebuggerDisplay("{Id} by {AuthorId} in reply to {SubjectId}")]
public class Comment
{
- public required Guid Id { get; set; }
+ public required string Id { get; set; }
+ public required string SubjectId { get; set; }
public required string Content { get; set; }
public required Guid AuthorId { get; set; }
public required DateTimeOffset CreatedAt { get; set; }
- public required int ReplyCount { get; set; }
- public required ISet LastRepliesAuthorIds { get; set; }
+ public required int CommentCount { get; set; }
+ public required IList LastCommentsAuthorIds { get; set; }
public required IDictionary ReactionCounters { get; set; }
- public required ISet ViewerReactions { get; set; }
+ public required IList ViewerReactions { get; set; }
}
\ 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 e4ba88f..9ec1e2b 100644
--- a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs
+++ b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs
@@ -1,14 +1,18 @@
+using System.Diagnostics;
+
namespace CrowdParlay.Social.Domain.Entities;
+
+[DebuggerDisplay("{Id} by {AuthorId}")]
public class Discussion
{
- public required Guid Id { get; set; }
+ public required string Id { get; set; }
public required string Title { get; set; }
- public required string Description { get; set; }
+ public required string Content { get; set; }
public required Guid AuthorId { get; set; }
public required DateTimeOffset CreatedAt { get; set; }
public required int CommentCount { get; set; }
- public required ISet LastCommentsAuthorIds { get; set; }
+ public required IList LastCommentsAuthorIds { get; set; }
public required IDictionary ReactionCounters { get; set; }
- public required ISet ViewerReactions { get; set; }
+ public required IList ViewerReactions { get; set; }
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs b/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs
deleted file mode 100644
index b9b5749..0000000
--- a/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using CrowdParlay.Social.Domain.ValueObjects;
-using Mapster;
-
-namespace CrowdParlay.Social.Domain;
-
-public class ReactionMapsterAdapterConfigurator
-{
- public static void Configure()
- {
- TypeAdapterConfig.NewConfig()
- .MapWith(source => new Reaction(source));
-
- TypeAdapterConfig.NewConfig()
- .MapWith(source => source.Value);
- }
-}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj b/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj
index 78cdaa0..8c5a6f5 100644
--- a/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj
+++ b/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj
@@ -1,20 +1,20 @@
- net8.0
+ net9.0
enable
enable
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs
index 3aed0a5..c6c1fcc 100644
--- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs
@@ -1,42 +1,47 @@
-using CrowdParlay.Social.Domain;
using CrowdParlay.Social.Domain.Abstractions;
using CrowdParlay.Social.Infrastructure.Persistence.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using Neo4j.Driver;
+using Microsoft.Extensions.Options;
+using MongoDB.Driver;
namespace CrowdParlay.Social.Infrastructure.Persistence;
public static class ConfigurePersistenceExtensions
{
public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) => services
- .AddNeo4j(configuration)
- .AddScoped()
+ .AddMongoDb(configuration)
.AddScoped()
.AddScoped()
- .AddScoped()
- .AddScoped(provider => provider.GetRequiredService().AsyncSession())
- .AddScoped(provider => provider.GetRequiredService())
- .AddScoped();
+ .AddScoped()
+ .AddHostedService()
+ .AddHostedService();
- // ReSharper disable once InconsistentNaming
- private static IServiceCollection AddNeo4j(this IServiceCollection services, IConfiguration configuration)
+ private static IServiceCollection AddMongoDb(this IServiceCollection services, IConfiguration configuration)
{
- ReactionMapsterAdapterConfigurator.Configure();
-
- var uri =
- configuration["NEO4J_URI"] ??
- throw new InvalidOperationException("NEO4J_URI is not set!");
+ services
+ .AddOptions()
+ .ValidateDataAnnotations()
+ .ValidateOnStart()
+ .Bind(configuration.GetSection("MongoDb"));
- var username =
- configuration["NEO4J_USERNAME"] ??
- throw new InvalidOperationException("NEO4J_USERNAME is not set!");
+ services.AddSingleton(provider =>
+ {
+ var settings = provider.GetRequiredService>();
+ return new MongoClient(settings.Value.ConnectionString);
+ });
- var password =
- configuration["NEO4J_PASSWORD"] ??
- throw new InvalidOperationException("NEO4J_PASSWORD is not set!");
+ services.AddScoped(provider =>
+ {
+ var client = provider.GetRequiredService();
+ return client.StartSession();
+ });
- var driver = GraphDatabase.Driver(uri, AuthTokens.Basic(username, password));
- return services.AddSingleton(driver);
+ return services.AddScoped(provider =>
+ {
+ var session = provider.GetRequiredService();
+ var settings = provider.GetRequiredService>();
+ return session.Client.GetDatabase(settings.Value.Database);
+ });
}
}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj b/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj
index 936fb4f..4ea9d58 100644
--- a/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj
@@ -1,16 +1,16 @@
- net8.0
+ net9.0
enable
enable
-
-
-
-
+
+
+
+
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs
new file mode 100644
index 0000000..4222c1f
--- /dev/null
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs
@@ -0,0 +1,22 @@
+using System.Diagnostics;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace CrowdParlay.Social.Infrastructure.Persistence.Models;
+
+[DebuggerDisplay("{Id} by {AuthorId} in reply to {SubjectId}")]
+public class CommentDocument : ISubjectDocument
+{
+ [BsonId] public required ObjectId Id { get; set; }
+ public required DateTimeOffset CreatedAt { get; set; }
+ public required Guid AuthorId { get; set; }
+ public required ObjectId SubjectId { get; set; }
+ public required string Content { get; set; }
+ public required int CommentCount { get; set; }
+ public required IList LastCommentsAuthorIds { get; set; }
+ public required IDictionary ReactionCounters { get; set; }
+ public required IDictionary ReactionsByAuthorId { get; set; }
+ [BsonIgnoreIfNull] public IList? Ancestors { get; set; }
+ [BsonIgnoreIfNull] public IList? Ascendants { get; set; }
+ [BsonIgnoreIfNull] public int? Depth { get; set; }
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs
new file mode 100644
index 0000000..acf8db1
--- /dev/null
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs
@@ -0,0 +1,19 @@
+using System.Diagnostics;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace CrowdParlay.Social.Infrastructure.Persistence.Models;
+
+[DebuggerDisplay("{Id} by {AuthorId}")]
+public class DiscussionDocument : ISubjectDocument
+{
+ [BsonId] public required ObjectId Id { get; set; }
+ public required DateTimeOffset CreatedAt { get; set; }
+ public required Guid AuthorId { get; set; }
+ public required string Title { get; set; }
+ public required string Content { get; set; }
+ public required int CommentCount { get; set; }
+ public required IList LastCommentsAuthorIds { get; set; }
+ public required IDictionary ReactionCounters { get; set; }
+ public required IDictionary ReactionsByAuthorId { get; set; }
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/ISubjectDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/ISubjectDocument.cs
new file mode 100644
index 0000000..a5d35a4
--- /dev/null
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/ISubjectDocument.cs
@@ -0,0 +1,12 @@
+using MongoDB.Bson;
+
+namespace CrowdParlay.Social.Infrastructure.Persistence.Models;
+
+public interface ISubjectDocument
+{
+ public ObjectId Id { get; set; }
+ public IDictionary ReactionCounters { get; set; }
+ public IDictionary ReactionsByAuthorId { get; set; }
+ public int CommentCount { get; set; }
+ public IList LastCommentsAuthorIds { get; set; }
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbConstants.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbConstants.cs
new file mode 100644
index 0000000..1f1042f
--- /dev/null
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbConstants.cs
@@ -0,0 +1,10 @@
+namespace CrowdParlay.Social.Infrastructure.Persistence;
+
+public static class MongoDbConstants
+{
+ public static class Collections
+ {
+ public const string Comments = "comments";
+ public const string Discussions = "discussions";
+ }
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbSettings.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbSettings.cs
new file mode 100644
index 0000000..c5b0e2f
--- /dev/null
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbSettings.cs
@@ -0,0 +1,9 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CrowdParlay.Social.Infrastructure.Persistence;
+
+internal record MongoDbSettings
+{
+ [Required] public required string ConnectionString { get; init; }
+ [Required] public required string Database { get; init; }
+}
\ No newline at end of file
diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorsRepository.cs
deleted file mode 100644
index 5131e09..0000000
--- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorsRepository.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using CrowdParlay.Social.Domain.Abstractions;
-using Neo4j.Driver;
-
-namespace CrowdParlay.Social.Infrastructure.Persistence.Services;
-
-public class AuthorsRepository(IAsyncQueryRunner runner) : IAuthorsRepository
-{
- public async Task EnsureCreatedAsync(Guid authorId) =>
- await runner.RunAsync("MERGE (author:Author { Id: $authorId })", new { authorId = authorId.ToString() });
-}
\ 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 09a6e1e..8610f21 100644
--- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs
+++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs
@@ -1,206 +1,182 @@
-using System.Text;
+using System.Linq.Expressions;
using CrowdParlay.Social.Application.Exceptions;
using CrowdParlay.Social.Domain.Abstractions;
using CrowdParlay.Social.Domain.DTOs;
using CrowdParlay.Social.Domain.Entities;
-using Mapster;
-using Neo4j.Driver;
+using CrowdParlay.Social.Infrastructure.Persistence.Models;
+using MongoDB.Bson;
+using MongoDB.Driver;
+using static CrowdParlay.Social.Infrastructure.Persistence.MongoDbConstants;
+using static MongoDB.Driver.PipelineStageDefinitionBuilder;
+using static MongoDB.Driver.Builders;
+using static MongoDB.Driver.PipelineDefinition;
namespace CrowdParlay.Social.Infrastructure.Persistence.Services;
-public class CommentsRepository(IAsyncQueryRunner runner) : ICommentsRepository
+public class CommentsRepository(IClientSessionHandle session, IMongoDatabase database) : ICommentsRepository
{
- public async Task GetByIdAsync(Guid commentId, Guid? viewerId)
- {
- var data = await runner.RunAsync(
- """
- MATCH (comment:Comment { Id: $commentId })-[:AUTHORED_BY]->(author:Author)
- OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(comment)
- OPTIONAL MATCH (comment)<-[reaction:REACTED_TO]-(:Author)
- OPTIONAL MATCH (comment)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId })
-
- WITH author, comment, deepReply, deepReplyAuthor, reaction,
- collect(viewerReaction.Value) AS viewerReactions
-
- WITH author, comment, deepReply, deepReplyAuthor, viewerReactions,
- reaction.Value AS reactionValue, count(reaction) AS reactionCount
-
- WITH author, comment, deepReply, deepReplyAuthor, viewerReactions,
- apoc.map.fromPairs(collect([reactionValue, reactionCount])) AS reactionCounters
-
- ORDER BY deepReply.CreatedAt DESC
-
- WITH author, comment, viewerReactions, reactionCounters,
- count(deepReply) AS deepReplyCount,
- collect(DISTINCT deepReplyAuthor.Id)[0..3] AS lastDeepRepliesAuthorIds
-
- RETURN {
- Id: comment.Id,
- Content: comment.Content,
- AuthorId: author.Id,
- CreatedAt: comment.CreatedAt,
- ReplyCount: deepReplyCount,
- LastRepliesAuthorIds: lastDeepRepliesAuthorIds,
- ReactionCounters: reactionCounters,
- ViewerReactions: viewerReactions
- }
- """,
- new
- {
- commentId = commentId.ToString(),
- viewerId = viewerId?.ToString()
- });
+ private readonly IMongoCollection _comments = database.GetCollection(Collections.Comments);
+ private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, Collections.Comments);
- if (await data.PeekAsync() is null)
- throw new NotFoundException();
+ public async Task GetByIdAsync(string commentId, Guid? viewerId)
+ {
+ var pipeline = _comments
+ .Find(session, comment => comment.Id == ObjectId.Parse(commentId))
+ .Project(CreateCommentProjectionExpression(viewerId));
- var record = await data.SingleAsync();
- return record[0].Adapt();
+ return await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException();
}
- public async Task> SearchAsync(Guid? subjectId, Guid? authorId, Guid? viewerId, int offset, int count)
+ public async Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count)
{
- var matchSelector = new StringBuilder("MATCH (comment:Comment)-[:AUTHORED_BY]->(author:Author)");
+ var matchPipeline = _comments.Aggregate(session);
- if (subjectId is not null)
+ if (flatten)
{
- matchSelector.AppendLine("MATCH (comment)-[:REPLIES_TO]->(subject { Id: $subjectId })");
- matchSelector.AppendLine("WHERE (subject:Comment OR subject:Discussion)");
+ matchPipeline = matchPipeline
+ .Limit(1)
+ .GraphLookup, CommentDocument>(
+ from: _comments,
+ startWith: _ => ObjectId.Parse(subjectId),
+ connectFromField: comment => comment.Id,
+ connectToField: comment => comment.SubjectId,
+ @as: comment => comment.Ascendants!,
+ depthField: comment => comment.Depth!.Value)
+ .Unwind(comment => comment.Ascendants)
+ .ReplaceRoot("$ascendants");
}
-
- if (authorId is not null)
- matchSelector.AppendLine("WHERE author.Id = $authorId");
-
- var data = await runner.RunAsync(
- matchSelector +
- """
- OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(comment)
-
- OPTIONAL MATCH (comment)<-[reaction:REACTED_TO]-(:Author)
- OPTIONAL MATCH (comment)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId })
-
- WITH author, comment, deepReplyAuthor, deepReply, reaction,
- collect(viewerReaction.Value) AS viewerReactions
-
- WITH author, comment, deepReplyAuthor, deepReply, viewerReactions,
- reaction.Value AS reactionValue, count(reaction) AS reactionCount
-
- WITH author, comment, deepReplyAuthor, deepReply, viewerReactions,
- apoc.map.fromPairs(collect([reactionValue, reactionCount])) AS reactionCounters
-
- ORDER BY comment.CreatedAt, deepReply.CreatedAt DESC
-
- WITH author, comment, viewerReactions, reactionCounters,
- COUNT(deepReply) AS deepReplyCount,
- COLLECT(DISTINCT deepReplyAuthor.Id)[0..3] AS lastDeepRepliesAuthorIds
-
- RETURN {
- TotalCount: COUNT(comment),
- Items: COLLECT({
- Id: comment.Id,
- Content: comment.Content,
- AuthorId: author.Id,
- CreatedAt: comment.CreatedAt,
- ReplyCount: deepReplyCount,
- LastRepliesAuthorIds: lastDeepRepliesAuthorIds,
- ReactionCounters: reactionCounters,
- ViewerReactions: viewerReactions
- })[$offset..$offset + $count]
- }
- """,
- new
- {
- subjectId = subjectId?.ToString(),
- authorId = authorId?.ToString(),
- viewerId = viewerId?.ToString(),
- offset,
- count
- });
-
- if (await data.PeekAsync() is null)
+ else
{
- return new Page
- {
- TotalCount = 0,
- Items = []
- };
+ matchPipeline = matchPipeline
+ .Match(comment => comment.SubjectId == ObjectId.Parse(subjectId));
}
- var record = await data.SingleAsync();
- return record[0].Adapt>();
+ var pipeline = matchPipeline.Facet(
+ new AggregateFacet(
+ "items",
+ PipelineDefinition.Create(
+ [
+ Sort(Builders.Sort.Ascending(comment => comment.Id)),
+ Skip(offset),
+ Limit(count),
+ Project(CreateCommentProjectionExpression(viewerId))
+ ])
+ ),
+ new AggregateFacet(
+ "totalCount",
+ Create([Count()])
+ )
+ );
+
+ var result = await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException();
+ return new Page
+ {
+ Items = result.Facets[0].Output() ?? Enumerable.Empty(),
+ TotalCount = (int)(result.Facets[1].Output().FirstOrDefault()?.Count ?? 0)
+ };
}
- public async Task CreateAsync(Guid authorId, Guid discussionId, string content)
+ public async Task CreateAsync(string? subjectId, Guid authorId, string content)
{
- var data = await runner.RunAsync(
- """
- MATCH (discussion:Discussion { Id: $discussionId })
- MERGE (author:Author { Id: $authorId })
- CREATE (comment:Comment {
- Id: randomUUID(),
- Content: $content,
- CreatedAt: datetime()
- })
- CREATE (discussion)<-[:REPLIES_TO]-(comment)-[:AUTHORED_BY]->(author)
- RETURN comment.Id
- """,
- new
- {
- authorId = authorId.ToString(),
- discussionId = discussionId.ToString(),
- content
- });
-
- if (await data.PeekAsync() is null)
- throw new NotFoundException();
-
- var record = await data.SingleAsync();
- return record[0].Adapt();
+ var reply = new CommentDocument
+ {
+ Id = ObjectId.GenerateNewId(),
+ CreatedAt = DateTimeOffset.UtcNow,
+ AuthorId = authorId,
+ SubjectId = ObjectId.Parse(subjectId),
+ Content = content,
+ CommentCount = 0,
+ LastCommentsAuthorIds = [],
+ ReactionCounters = new Dictionary(),
+ ReactionsByAuthorId = new Dictionary()
+ };
+
+ await _comments.InsertOneAsync(session, reply);
+ return reply.Id.ToString();
}
- public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content)
+ public async Task> GetAncestorsAsync(string commentId, Guid? viewerId) => await _comments.Aggregate(session)
+ .Match(comment => comment.Id == ObjectId.Parse(commentId))
+ .GraphLookup, CommentDocument>(
+ from: _comments,
+ startWith: comment => comment.SubjectId,
+ connectFromField: comment => comment.SubjectId,
+ connectToField: comment => comment.Id,
+ @as: comment => comment.Ancestors!,
+ depthField: comment => comment.Depth!.Value)
+ .Unwind(comment => comment.Ancestors)
+ .ReplaceRoot("$ancestors")
+ .SortBy(comment => comment.Depth)
+ .Project(CreateCommentProjectionExpression(viewerId))
+ .ToListAsync();
+
+ public async Task IncludeCommentInAncestorsMetadataAsync(IEnumerable ancestors, Guid authorId)
{
- var cursor = await runner.RunAsync(
- """
- MATCH (parent:Comment {Id: $parentCommentId})
- MERGE (replyAuthor:Author { Id: $authorId })
- CREATE (reply:Comment {
- Id: randomUUID(),
- Content: $content,
- CreatedAt: datetime()
- })
- CREATE (parent)<-[:REPLIES_TO]-(reply)-[:AUTHORED_BY]->(replyAuthor)
- RETURN reply.Id
- """,
- new
+ var updates = ancestors.Select(ancestor =>
{
- parentCommentId = parentCommentId.ToString(),
- authorId = authorId.ToString(),
- content
- });
+ var newLastCommentsAuthorIds = ancestor.LastCommentsAuthorIds
+ .Except([authorId])
+ .Append(authorId)
+ .TakeLast(3)
+ .ToList();
+
+ var filter = Filter.Eq(comment => comment.Id, ObjectId.Parse(ancestor.Id));
+ var update = Update
+ .Set(comment => comment.LastCommentsAuthorIds, newLastCommentsAuthorIds)
+ .Inc(comment => comment.CommentCount, 1);
+
+ return new UpdateOneModel(filter, update);
+ })
+ .ToList();
- if (await cursor.PeekAsync() is null)
- throw new NotFoundException();
+ if (updates.Any())
+ await _comments.BulkWriteAsync(session, updates);
+ }
- var record = await cursor.SingleAsync();
- return record[0].Adapt();
+ public async Task ExcludeCommentFromAncestorsMetadataAsync(IEnumerable ancestors)
+ {
+ var filter = Filter.In(comment => comment.Id, ancestors.Select(comment => ObjectId.Parse(comment.Id)));
+ var update = Update.Inc(comment => comment.CommentCount, -1);
+ await _comments.UpdateManyAsync(session, filter, update);
}
- public async Task DeleteAsync(Guid commentId)
+ public async Task IncludeCommentInMetadataAsync(string discussionId, Guid authorId) =>
+ await _subjectsRepository.IncludeCommentInMetadataAsync(discussionId, authorId);
+
+ public async Task ExcludeCommentFromMetadataAsync(string discussionId) =>
+ await _subjectsRepository.ExcludeCommentFromMetadataAsync(discussionId);
+
+ public async Task DeleteAsync(string commentId)
{
- var data = await runner.RunAsync(
- """
- OPTIONAL MATCH (comment:Comment { Id: $commentId })
- OPTIONAL MATCH (reply:Comment)-[:REPLIES_TO*]->(comment)
- DETACH DELETE comment, reply
- RETURN COUNT(comment) = 0
- """,
- new { commentId = commentId.ToString() });
-
- var record = await data.SingleAsync();
- var notFount = record[0].As();
-
- if (notFount)
+ var deleteResult = await _comments.DeleteOneAsync(session, comment => comment.Id == ObjectId.Parse(commentId));
+ if (deleteResult.DeletedCount == 0)
throw new NotFoundException();
}
+
+ public async Task> GetReactionsAsync(string commentId, Guid authorId) =>
+ await _subjectsRepository.GetReactionsAsync(commentId, authorId);
+
+ public async Task SetReactionsAsync(string commentId, Guid authorId, ISet