Skip to content

Commit b6bbc12

Browse files
committed
feat(reactions): redesign reactions API
1 parent 2cd4d41 commit b6bbc12

29 files changed

+271
-261
lines changed

src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public override async Task OnDisconnectedAsync(Exception? exception)
2222
private Guid GetDiscussionIdFromQuery() =>
2323
Guid.TryParse(GetSingleQueryParameterValueFromQuery(DiscussionIdQueryParameterName), out var discussionId)
2424
? discussionId
25-
: throw new ValidationException(DiscussionIdQueryParameterName, ["Must be a valid GUID."]);
25+
: throw new ValidationException(DiscussionIdQueryParameterName, ["Must be a valid UUID."]);
2626

2727
private string GetSingleQueryParameterValueFromQuery(string key)
2828
{
Lines changed: 46 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,110 @@
1-
using System.Net;
21
using System.Net.Mime;
32
using CrowdParlay.Social.Api.Extensions;
43
using CrowdParlay.Social.Api.Hubs;
5-
using CrowdParlay.Social.Api.v1.DTOs;
64
using CrowdParlay.Social.Application.Abstractions;
75
using CrowdParlay.Social.Application.DTOs;
8-
using CrowdParlay.Social.Application.Exceptions;
96
using CrowdParlay.Social.Domain.DTOs;
10-
using CrowdParlay.Social.Domain.ValueObjects;
117
using Microsoft.AspNetCore.Authorization;
128
using Microsoft.AspNetCore.Mvc;
139
using Microsoft.AspNetCore.Mvc.ModelBinding;
1410
using Microsoft.AspNetCore.SignalR;
11+
using static Microsoft.AspNetCore.Http.StatusCodes;
1512

1613
namespace CrowdParlay.Social.Api.v1.Controllers;
1714

1815
[ApiController, ApiRoute("[controller]")]
19-
public class CommentsController(ICommentsService comments, IHubContext<CommentsHub> commentHub) : ControllerBase
16+
public class CommentsController(
17+
ICommentsService commentsService,
18+
IReactionsService reactionsService,
19+
IHubContext<CommentsHub> commentHub) : ControllerBase
2020
{
2121
/// <summary>
2222
/// Returns comment with the specified ID.
2323
/// </summary>
2424
[HttpGet("{commentId:guid}")]
2525
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
26-
[ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.OK)]
27-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
28-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
29-
public async Task<CommentDto> GetCommentById([FromRoute] Guid commentId) =>
30-
await comments.GetByIdAsync(commentId, User.GetUserId());
26+
[ProducesResponseType<CommentResponse>(Status200OK)]
27+
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
28+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
29+
public async Task<CommentResponse> GetById([FromRoute] Guid commentId) =>
30+
await commentsService.GetByIdAsync(commentId, User.GetUserId());
3131

3232
/// <summary>
3333
/// Get comments by filters.
3434
/// </summary>
3535
[HttpGet]
3636
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
37-
[ProducesResponseType(typeof(Page<CommentDto>), (int)HttpStatusCode.OK)]
38-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
39-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
40-
public async Task<Page<CommentDto>> SearchComments(
37+
[ProducesResponseType<Page<CommentResponse>>(Status200OK)]
38+
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
39+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
40+
public async Task<Page<CommentResponse>> Search(
4141
[FromQuery] Guid? discussionId,
4242
[FromQuery] Guid? authorId,
4343
[FromQuery, BindRequired] int offset,
4444
[FromQuery, BindRequired] int count) =>
45-
await comments.SearchAsync(discussionId, authorId, User.GetUserId(), offset, count);
45+
await commentsService.SearchAsync(discussionId, authorId, User.GetUserId(), offset, count);
4646

4747
/// <summary>
4848
/// Creates a top-level comment in discussion.
4949
/// </summary>
5050
[HttpPost, Authorize]
5151
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
52-
[ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.Created)]
53-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
54-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
55-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)]
56-
public async Task<ActionResult<CommentDto>> Create([FromBody] CommentRequest request)
52+
[ProducesResponseType<CommentResponse>(Status201Created)]
53+
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
54+
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
55+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
56+
public async Task<ActionResult<CommentResponse>> Create([FromBody] CommentRequest request)
5757
{
58-
var authorId =
59-
User.GetUserId()
60-
?? throw new ForbiddenException();
61-
62-
var response = await comments.CreateAsync(authorId, request.DiscussionId, request.Content);
58+
var response = await commentsService.CreateAsync(User.GetRequiredUserId(), request.DiscussionId, request.Content);
6359

6460
_ = commentHub.Clients
6561
.Group(CommentsHub.GroupNames.NewCommentInDiscussion(request.DiscussionId))
6662
.SendCoreAsync(CommentsHub.Events.NewComment.ToString(), [response]);
6763

68-
return CreatedAtAction(nameof(GetCommentById), new { commentId = response.Id }, response);
64+
return CreatedAtAction(nameof(GetById), new { commentId = response.Id }, response);
6965
}
7066

7167
/// <summary>
7268
/// Get replies to the comment with the specified ID.
7369
/// </summary>
7470
[HttpGet("{parentCommentId:guid}/replies")]
7571
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
76-
[ProducesResponseType(typeof(Page<CommentDto>), (int)HttpStatusCode.OK)]
77-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
78-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
79-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
80-
public async Task<Page<CommentDto>> GetRepliesToComment(
72+
[ProducesResponseType<Page<CommentResponse>>(Status200OK)]
73+
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
74+
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
75+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
76+
public async Task<Page<CommentResponse>> GetReplies(
8177
[FromRoute] Guid parentCommentId,
8278
[FromQuery, BindRequired] int offset,
8379
[FromQuery, BindRequired] int count) =>
84-
await comments.GetRepliesToCommentAsync(parentCommentId, User.GetUserId(), offset, count);
80+
await commentsService.GetRepliesToCommentAsync(parentCommentId, User.GetUserId(), offset, count);
8581

8682
/// <summary>
8783
/// Creates a reply to the comment with the specified ID.
8884
/// </summary>
8985
[HttpPost("{parentCommentId:guid}/replies"), Authorize]
9086
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
91-
[ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.Created)]
92-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
93-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
94-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)]
95-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
96-
public async Task<ActionResult<CommentDto>> ReplyToComment([FromRoute] Guid parentCommentId, [FromBody] ReplyRequest request)
87+
[ProducesResponseType<CommentResponse>(Status201Created)]
88+
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
89+
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
90+
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
91+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
92+
public async Task<ActionResult<CommentResponse>> Reply([FromRoute] Guid parentCommentId, [FromBody] ReplyRequest request)
9793
{
98-
var response = await comments.ReplyToCommentAsync(User.GetRequiredUserId(), parentCommentId, request.Content);
99-
return CreatedAtAction(nameof(GetCommentById), new { commentId = response.Id }, response);
94+
var response = await commentsService.ReplyToCommentAsync(User.GetRequiredUserId(), parentCommentId, request.Content);
95+
return CreatedAtAction(nameof(GetById), new { commentId = response.Id }, response);
10096
}
10197

10298
/// <summary>
103-
/// Add a reaction to a comment
104-
/// </summary>
105-
[HttpPost("{commentId:guid}/reactions"), Authorize]
106-
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
107-
[ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.OK)]
108-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
109-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
110-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)]
111-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
112-
public async Task<CommentDto> AddReaction([FromRoute] Guid commentId, [FromBody] string reaction) =>
113-
await comments.AddReactionAsync(User.GetRequiredUserId(), commentId, reaction);
114-
115-
/// <summary>
116-
/// Remove a reaction from a comment
99+
/// Sets reactions to a comment.
117100
/// </summary>
118-
[HttpDelete("{commentId:guid}/reactions"), Authorize]
101+
[HttpPost("{discussionId:guid}/reactions"), Authorize]
119102
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
120-
[ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.OK)]
121-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
122-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
123-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)]
124-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
125-
public async Task<CommentDto> RemoveReaction([FromRoute] Guid commentId, [FromBody] string reaction) =>
126-
await comments.AddReactionAsync(User.GetRequiredUserId(), commentId, reaction);
127-
}
103+
[ProducesResponseType(Status204NoContent)]
104+
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
105+
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
106+
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
107+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
108+
public async Task React([FromRoute] Guid discussionId, [FromBody] ISet<string> reactions) =>
109+
await reactionsService.SetAsync(discussionId, User.GetRequiredUserId(), reactions);
110+
}
Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,78 @@
1-
using System.Net;
21
using System.Net.Mime;
32
using CrowdParlay.Social.Api.Extensions;
4-
using CrowdParlay.Social.Api.v1.DTOs;
53
using CrowdParlay.Social.Application.Abstractions;
64
using CrowdParlay.Social.Application.DTOs;
75
using CrowdParlay.Social.Domain.DTOs;
86
using Microsoft.AspNetCore.Authorization;
97
using Microsoft.AspNetCore.Mvc;
108
using Microsoft.AspNetCore.Mvc.ModelBinding;
9+
using static Microsoft.AspNetCore.Http.StatusCodes;
1110

1211
namespace CrowdParlay.Social.Api.v1.Controllers;
1312

1413
[ApiController, ApiRoute("[controller]")]
15-
public class DiscussionsController(IDiscussionsService discussions) : ControllerBase
14+
public class DiscussionsController(IDiscussionsService discussionsService, IReactionsService reactionsService) : ControllerBase
1615
{
1716
/// <summary>
1817
/// Returns discussion with the specified ID.
1918
/// </summary>
2019
[HttpGet("{discussionId:guid}")]
2120
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
22-
[ProducesResponseType(typeof(DiscussionDto), (int)HttpStatusCode.OK)]
23-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
24-
public async Task<DiscussionDto> GetDiscussionById([FromRoute] Guid discussionId) =>
25-
await discussions.GetByIdAsync(discussionId, User.GetUserId());
21+
[ProducesResponseType<DiscussionResponse>(Status200OK)]
22+
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
23+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
24+
public async Task<DiscussionResponse> GetById([FromRoute] Guid discussionId) =>
25+
await discussionsService.GetByIdAsync(discussionId, User.GetUserId());
2626

2727
/// <summary>
2828
/// Returns all discussions created by author with the specified ID.
2929
/// </summary>
3030
[HttpGet]
3131
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
32-
[ProducesResponseType(typeof(Page<DiscussionDto>), (int)HttpStatusCode.OK)]
33-
public async Task<Page<DiscussionDto>> SearchDiscussions(
32+
[ProducesResponseType<Page<DiscussionResponse>>(Status200OK)]
33+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
34+
public async Task<Page<DiscussionResponse>> Search(
3435
[FromQuery] Guid? authorId,
3536
[FromQuery, BindRequired] int offset,
3637
[FromQuery, BindRequired] int count) =>
37-
await discussions.SearchAsync(authorId, User.GetUserId(), offset, count);
38+
await discussionsService.SearchAsync(authorId, User.GetUserId(), offset, count);
3839

3940
/// <summary>
4041
/// Creates a discussion.
4142
/// </summary>
4243
[HttpPost, Authorize]
4344
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
44-
[ProducesResponseType(typeof(DiscussionDto), (int)HttpStatusCode.Created)]
45-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)]
46-
public async Task<ActionResult<DiscussionDto>> CreateDiscussion([FromBody] DiscussionRequest request)
45+
[ProducesResponseType<DiscussionResponse>(Status201Created)]
46+
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
47+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
48+
public async Task<ActionResult<DiscussionResponse>> Create([FromBody] DiscussionRequest request)
4749
{
48-
var response = await discussions.CreateAsync(User.GetRequiredUserId(), request.Title, request.Description);
49-
return CreatedAtAction(nameof(GetDiscussionById), new { DiscussionId = response.Id }, response);
50+
var response = await discussionsService.CreateAsync(User.GetRequiredUserId(), request.Title, request.Description);
51+
return CreatedAtAction(nameof(GetById), new { DiscussionId = response.Id }, response);
5052
}
5153

5254
/// <summary>
53-
/// Add a reaction to a comment
55+
/// Modifies a discussion.
5456
/// </summary>
55-
[HttpPost("{discussionId:guid}/reactions"), Authorize]
57+
[HttpPatch("{discussionId:guid}"), Authorize]
5658
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
57-
[ProducesResponseType(typeof(DiscussionDto), (int)HttpStatusCode.OK)]
58-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
59-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
60-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)]
61-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
62-
public async Task<DiscussionDto> AddReaction([FromRoute] Guid discussionId, [FromBody] string reaction) =>
63-
await discussions.AddReactionAsync(User.GetRequiredUserId(), discussionId, reaction);
59+
[ProducesResponseType<DiscussionResponse>(Status200OK)]
60+
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
61+
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
62+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
63+
public async Task<DiscussionResponse> Update([FromRoute] Guid discussionId, [FromBody] UpdateDiscussionRequest request) =>
64+
await discussionsService.UpdateAsync(discussionId, User.GetRequiredUserId(), request);
6465

6566
/// <summary>
66-
/// Remove a reaction from a comment
67+
/// Sets reactions to a discussion.
6768
/// </summary>
68-
[HttpDelete("{commentId:guid}/reactions"), Authorize]
69+
[HttpPost("{discussionId:guid}/reactions"), Authorize]
6970
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
70-
[ProducesResponseType(typeof(DiscussionDto), (int)HttpStatusCode.OK)]
71-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)]
72-
[ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)]
73-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)]
74-
[ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)]
75-
public async Task<DiscussionDto> RemoveReaction([FromRoute] Guid discussionId, [FromBody] string reaction) =>
76-
await discussions.AddReactionAsync(User.GetRequiredUserId(), discussionId, reaction);
71+
[ProducesResponseType(Status204NoContent)]
72+
[ProducesResponseType<ValidationProblemDetails>(Status400BadRequest)]
73+
[ProducesResponseType<ProblemDetails>(Status403Forbidden)]
74+
[ProducesResponseType<ProblemDetails>(Status404NotFound)]
75+
[ProducesResponseType<ProblemDetails>(Status500InternalServerError)]
76+
public async Task React([FromRoute] Guid discussionId, [FromBody] ISet<string> reactions) =>
77+
await reactionsService.SetAsync(discussionId, User.GetRequiredUserId(), reactions);
7778
}

src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ namespace CrowdParlay.Social.Api.v1.Controllers;
77
[ApiController, ApiRoute("[controller]")]
88
public class LookupController : ControllerBase
99
{
10-
[HttpGet("reactions")]
11-
[Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)]
10+
[HttpGet("reactions"), Produces(MediaTypeNames.Application.Json)]
1211
public IReadOnlySet<string> GetAvailableReactions() => Reaction.AllowedValues;
1312
}

src/CrowdParlay.Social.Api/v1/DTOs/ReplyRequest.cs

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
using CrowdParlay.Social.Application.DTOs;
22
using CrowdParlay.Social.Domain.DTOs;
3-
using CrowdParlay.Social.Domain.ValueObjects;
43

54
namespace CrowdParlay.Social.Application.Abstractions;
65

76
public interface ICommentsService
87
{
9-
public Task<CommentDto> GetByIdAsync(Guid commentId, Guid? viewerId);
10-
public Task<Page<CommentDto>> SearchAsync(Guid? discussionId, Guid? authorId, Guid? viewerId, int offset, int count);
11-
public Task<CommentDto> CreateAsync(Guid authorId, Guid discussionId, string content);
12-
public Task<Page<CommentDto>> GetRepliesToCommentAsync(Guid parentCommentId, Guid? viewerId, int offset, int count);
13-
public Task<CommentDto> ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content);
14-
public Task<CommentDto> AddReactionAsync(Guid authorId, Guid commentId, Reaction reaction);
15-
public Task<CommentDto> RemoveReactionAsync(Guid authorId, Guid commentId, Reaction reaction);
8+
public Task<CommentResponse> GetByIdAsync(Guid commentId, Guid? viewerId);
9+
public Task<Page<CommentResponse>> SearchAsync(Guid? discussionId, Guid? authorId, Guid? viewerId, int offset, int count);
10+
public Task<CommentResponse> CreateAsync(Guid authorId, Guid discussionId, string content);
11+
public Task<Page<CommentResponse>> GetRepliesToCommentAsync(Guid parentCommentId, Guid? viewerId, int offset, int count);
12+
public Task<CommentResponse> ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content);
1613
public Task DeleteAsync(Guid id);
1714
}

0 commit comments

Comments
 (0)