Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,4 +33,4 @@ jobs:
with:
name: .NET test results
path: "**/TestResults/*.trx"
reporter: dotnet-trx
reporter: dotnet-trx
6 changes: 0 additions & 6 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -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
12 changes: 3 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# Crowd Parlay's *social* microservice [![Test](https://github.com/crowdparlay/social/actions/workflows/test.yml/badge.svg)](https://github.com/crowdparlay/social/actions/workflows/test.yml)
- **languages:** <kbd>C#</kbd> <kbd>Cypher</kbd>
- **technologies:** <kbd>.NET 8</kbd> <kbd>ASP.NET Core</kbd> <kbd>RabbitMQ</kbd> <kbd>Neo4j</kbd> <kbd>SSE</kbd> <kbd>OpenAPI</kbd>
- **dependencies:** <kbd>MassTransit</kbd> <kbd>SignalR</kbd> <kbd>MediatR</kbd> <kbd>FluentValidation</kbd> <kbd>Mapster</kbd> <kbd>Swashbuckle</kbd> <kbd>Testcontainers</kbd>

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:** <kbd>C# 13</kbd> <kbd>.NET 9</kbd> <kbd>ASP.NET Core</kbd> <kbd>MongoDb.Driver</kbd> <kbd>MassTransit</kbd> <kbd>OpenTelemetry</kbd> <kbd>SignalR</kbd> <kbd>FluentValidation</kbd> <kbd>Mapster</kbd> <kbd>Swashbuckle/OpenAPI</kbd> <kbd>Testcontainers</kbd>
- **Environment:** <kbd>MongoDB</kbd> <kbd>Redis</kbd> <kbd>RabbitMQ</kbd> <kbd>Elastic APM</kbd> [<kbd>crowdparlay/users</kbd>](https://github.com/crowdparlay/users)
- **Adopted but retired:** <kbd>Neo4j</kbd> <kbd>MediatR</kbd>

> [!NOTE]
> This Git repository contains Submodules. Don’t forget to clone with `--recurse-submodules`
3 changes: 0 additions & 3 deletions neo4j/migrations/V010__Add_indexes.cypher

This file was deleted.

This file was deleted.

12 changes: 0 additions & 12 deletions src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs

This file was deleted.

22 changes: 11 additions & 11 deletions src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
Expand All @@ -10,19 +10,19 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Elastic.Apm.NetCoreAll" Version="1.26.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="8.0.7" />
<PackageReference Include="Elastic.Apm.NetCoreAll" Version="1.32.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.1.0"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.2" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.8.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.5" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.9.0-beta.1" />
<PackageReference Include="Serilog.Enrichers.OpenTelemetry" Version="1.0.1" />
<PackageReference Include="SignalRSwaggerGen" Version="4.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="SignalRSwaggerGen" Version="4.8.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.2" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 0 additions & 2 deletions src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using CrowdParlay.Communication;
using CrowdParlay.Social.Api.Consumers;
using CrowdParlay.Social.Api.Services;
using MassTransit;

Expand All @@ -21,7 +20,6 @@ public static IServiceCollection AddApi(this IServiceCollection services, IConfi

return services.AddMassTransit(bus =>
{
bus.AddConsumersFromNamespaceContaining<UserEventConsumer>();
bus.UsingRabbitMq((context, configurator) =>
{
var amqpServerUrl =
Expand Down
20 changes: 9 additions & 11 deletions src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 32 additions & 58 deletions src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs
Original file line number Diff line number Diff line change
@@ -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<CommentsHub> commentHub) : ControllerBase
public class CommentsController(ICommentsService commentsService) : ControllerBase
{
/// <summary>
/// Returns comment with the specified ID.
/// Retrieves a comment by its unique identifier.
/// </summary>
[HttpGet("{commentId:guid}")]
/// <param name="commentId">The unique identifier of the comment to retrieve.</param>
/// <returns>The requested comment details.</returns>
[HttpGet("{commentId}")]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType<CommentResponse>(Status200OK)]
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
public async Task<CommentResponse> GetById([FromRoute] Guid commentId) =>
public async Task<CommentResponse> GetById([FromRoute] string commentId) =>
await commentsService.GetByIdAsync(commentId, User.GetUserId());

/// <summary>
/// Get comments by filters.
/// </summary>
[HttpGet]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType<Page<CommentResponse>>(Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
public async Task<Page<CommentResponse>> Search(
[FromQuery] Guid? discussionId,
[FromQuery] Guid? authorId,
[FromQuery, BindRequired] int offset,
[FromQuery, BindRequired] int count) =>
await commentsService.SearchAsync(discussionId, authorId, User.GetUserId(), offset, count);

/// <summary>
/// Creates a top-level comment in discussion.
/// </summary>
[HttpPost, Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType<CommentResponse>(Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
public async Task<ActionResult<CommentResponse>> 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);
}


/// <summary>
/// Get replies to the comment with the specified ID.
/// Retrieves replies to a specified comment with configurable tree traversal and pagination.
/// </summary>
[HttpGet("{parentCommentId:guid}/replies")]
/// <param name="commentId">The unique identifier of the parent comment.</param>
/// <param name="flatten">When true, returns all nested replies in a flat structure; otherwise returns direct children only.</param>
/// <param name="offset">The number of items to skip before starting to return results (pagination offset).</param>
/// <param name="count">The maximum number of items to return (pagination limit).</param>
/// <returns>A paginated list of comment replies.</returns>
[HttpGet("{commentId}/replies")]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType<Page<CommentResponse>>(Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
public async Task<Page<CommentResponse>> 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);
/// <summary>
/// Creates a reply to the comment with the specified ID.
/// Creates a new reply to a specified comment. Requires authenticated user.
/// </summary>
[HttpPost("{parentCommentId:guid}/replies"), Authorize]
/// <param name="commentId">The unique identifier of the comment being replied to.</param>
/// <param name="request">The content of the reply.</param>
/// <returns>The newly created reply comment details.</returns>
[HttpPost("{commentId}/replies"), Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType<CommentResponse>(Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
public async Task<ActionResult<CommentResponse>> Reply([FromRoute] Guid parentCommentId, [FromBody] ReplyRequest request)
public async Task<ActionResult<CommentResponse>> 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);
}

/// <summary>
/// Sets reactions to a comment.
/// Updates reactions on a specified comment. Requires authenticated user.
/// </summary>
[HttpPost("{commentId:guid}/reactions"), Authorize]
/// <param name="commentId">The unique identifier of the comment to react to.</param>
/// <param name="reactions">The set of reaction identifiers to apply.</param>
/// <returns>No content response upon successful update.</returns>
[HttpPost("{commentId}/reactions"), Authorize]
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
public async Task<IActionResult> React([FromRoute] Guid commentId, [FromBody] ISet<string> reactions)
public async Task<IActionResult> React([FromRoute] string commentId, [FromBody] ISet<string> reactions)
{
await reactionsService.SetAsync(commentId, User.GetRequiredUserId(), reactions);
await commentsService.SetReactionsAsync(commentId, User.GetRequiredUserId(), reactions);
return NoContent();
}
}
Loading
Loading