Skip to content

Commit f8642ee

Browse files
mpauloskyCopilot
andauthored
feat: Issue Voting & Prioritization (v0.4.0) (#129)
* feat(#126): add Votes+VotedBy to Issue model; VoteIssueCommand/UnvoteIssueCommand - Issue.cs: add Votes (int) and VotedBy (List<string>) properties - IssueConfiguration.cs: configure Votes and VotedBy for MongoDB EF Core - IssueDto.cs: add Votes and VotedBy positional parameters; update constructors and Empty - IssueMapper.cs: map Votes and VotedBy in ToDto() and ToModel() - VoteIssueCommand.cs: sealed handler with duplicate-vote guard; proper error code propagation - UnvoteIssueCommand.cs: sealed handler with not-voted guard; proper error code propagation - VoteIssueCommandValidator.cs: FluentValidation for both vote commands - Tests: 12 new handler tests; fix all IssueDto positional constructions across test projects Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(#127): VotingService + vote API endpoints + SignalR broadcast - IVotingService.cs / VotingService.cs: scoped service wrapping VoteIssueCommand/ UnvoteIssueCommand via MediatR; extracts current user from IHttpContextAccessor - VoteEndpoints.cs: POST/DELETE /api/issues/{id}/vote; RequireAuthorization(UserPolicy); proper 400/404/500 mapping; broadcasts IssueVoted via SignalR hub context - Program.cs: register IHttpContextAccessor, IVotingService/VotingService scoped, add app.MapVoteEndpoints() - SignalRClientService.cs: add OnIssueVoted event + IssueVoted hub handler registration - BunitTestBase.cs: register IVotingService mock for component tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(#128): vote badge + Top Voted sort/filter on issues list UI - Index.razor: vote badge (▲ N) on issue cards; Top Voted quick-filter button that shows issues with Votes > 0 sorted by votes desc (client-side, no backend change); subscribes to OnIssueVoted SignalR event to auto-reload vote counts - Details.razor: inject IVotingService; show ▲ Vote / ▼ Unvote button for authenticated non-admin non-author users on non-archived issues; disabled while voting; optimistic local state update on success; subscribe to OnIssueVoted for real-time vote sync from other users Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: sanitize user-provided values in VotingService log statements (CodeQL CWE-117) Replace newline characters in userId and issueId before logging to prevent log injection attacks flagged by CodeQL as high-severity security alerts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bda0066 commit f8642ee

21 files changed

Lines changed: 980 additions & 12 deletions

File tree

src/Domain/DTOs/IssueDto.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ public record IssueDto(
2727
UserDto ArchivedBy,
2828
bool ApprovedForRelease,
2929
bool Rejected,
30-
UserDto Assignee)
30+
UserDto Assignee,
31+
int Votes,
32+
IReadOnlyList<string> VotedBy)
3133
{
3234
/// <summary>
3335
/// Initializes a new instance of the <see cref="IssueDto" /> record.
@@ -46,7 +48,9 @@ public IssueDto(Issue issue) : this(
4648
UserMapper.ToDto(issue.ArchivedBy),
4749
issue.ApprovedForRelease,
4850
issue.Rejected,
49-
UserMapper.ToDto(issue.Assignee))
51+
UserMapper.ToDto(issue.Assignee),
52+
issue.Votes,
53+
issue.VotedBy)
5054
{
5155
}
5256

@@ -63,5 +67,7 @@ public IssueDto(Issue issue) : this(
6367
UserDto.Empty,
6468
false,
6569
false,
66-
UserDto.Empty);
70+
UserDto.Empty,
71+
0,
72+
[]);
6773
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026. All rights reserved.
2+
3+
using Domain.Abstractions;
4+
5+
namespace Domain.Features.Issues.Commands;
6+
7+
/// <summary>
8+
/// Command to remove a vote from an issue.
9+
/// </summary>
10+
public record UnvoteIssueCommand(
11+
string IssueId,
12+
string UserId) : IRequest<Result<IssueDto>>;
13+
14+
/// <summary>
15+
/// Handler for removing a vote from an issue.
16+
/// </summary>
17+
public sealed class UnvoteIssueCommandHandler : IRequestHandler<UnvoteIssueCommand, Result<IssueDto>>
18+
{
19+
private readonly IRepository<Issue> _repository;
20+
private readonly ILogger<UnvoteIssueCommandHandler> _logger;
21+
22+
public UnvoteIssueCommandHandler(
23+
IRepository<Issue> repository,
24+
ILogger<UnvoteIssueCommandHandler> logger)
25+
{
26+
_repository = repository;
27+
_logger = logger;
28+
}
29+
30+
public async Task<Result<IssueDto>> Handle(UnvoteIssueCommand request, CancellationToken cancellationToken)
31+
{
32+
_logger.LogInformation("User {UserId} removing vote from issue {IssueId}", request.UserId, request.IssueId);
33+
34+
var existingResult = await _repository.GetByIdAsync(request.IssueId, cancellationToken);
35+
36+
if (existingResult.Failure)
37+
{
38+
_logger.LogWarning("Issue not found with ID: {IssueId}", request.IssueId);
39+
return Result.Fail<IssueDto>(existingResult.Error ?? "Issue not found", existingResult.ErrorCode);
40+
}
41+
42+
if (existingResult.Value is null)
43+
{
44+
return Result.Fail<IssueDto>("Issue not found", ResultErrorCode.NotFound);
45+
}
46+
47+
var issue = existingResult.Value;
48+
49+
if (!issue.VotedBy.Contains(request.UserId))
50+
{
51+
_logger.LogWarning("User {UserId} has not voted on issue {IssueId}", request.UserId, request.IssueId);
52+
return Result.Fail<IssueDto>("Not voted", ResultErrorCode.Validation);
53+
}
54+
55+
issue.VotedBy.Remove(request.UserId);
56+
issue.Votes = issue.VotedBy.Count;
57+
issue.DateModified = DateTime.UtcNow;
58+
59+
var result = await _repository.UpdateAsync(issue, cancellationToken);
60+
61+
if (result.Failure)
62+
{
63+
_logger.LogError("Failed to remove vote from issue {IssueId}: {Error}", request.IssueId, result.Error);
64+
return Result.Fail<IssueDto>(result.Error ?? "Failed to remove vote", result.ErrorCode);
65+
}
66+
67+
_logger.LogInformation("User {UserId} successfully removed vote from issue {IssueId}", request.UserId, request.IssueId);
68+
return Result.Ok(new IssueDto(result.Value!));
69+
}
70+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026. All rights reserved.
2+
3+
using Domain.Abstractions;
4+
5+
namespace Domain.Features.Issues.Commands;
6+
7+
/// <summary>
8+
/// Command to cast a vote on an issue.
9+
/// </summary>
10+
public record VoteIssueCommand(
11+
string IssueId,
12+
string UserId) : IRequest<Result<IssueDto>>;
13+
14+
/// <summary>
15+
/// Handler for casting a vote on an issue.
16+
/// </summary>
17+
public sealed class VoteIssueCommandHandler : IRequestHandler<VoteIssueCommand, Result<IssueDto>>
18+
{
19+
private readonly IRepository<Issue> _repository;
20+
private readonly ILogger<VoteIssueCommandHandler> _logger;
21+
22+
public VoteIssueCommandHandler(
23+
IRepository<Issue> repository,
24+
ILogger<VoteIssueCommandHandler> logger)
25+
{
26+
_repository = repository;
27+
_logger = logger;
28+
}
29+
30+
public async Task<Result<IssueDto>> Handle(VoteIssueCommand request, CancellationToken cancellationToken)
31+
{
32+
_logger.LogInformation("User {UserId} voting on issue {IssueId}", request.UserId, request.IssueId);
33+
34+
var existingResult = await _repository.GetByIdAsync(request.IssueId, cancellationToken);
35+
36+
if (existingResult.Failure)
37+
{
38+
_logger.LogWarning("Issue not found with ID: {IssueId}", request.IssueId);
39+
return Result.Fail<IssueDto>(existingResult.Error ?? "Issue not found", existingResult.ErrorCode);
40+
}
41+
42+
if (existingResult.Value is null)
43+
{
44+
return Result.Fail<IssueDto>("Issue not found", ResultErrorCode.NotFound);
45+
}
46+
47+
var issue = existingResult.Value;
48+
49+
if (issue.VotedBy.Contains(request.UserId))
50+
{
51+
_logger.LogWarning("User {UserId} has already voted on issue {IssueId}", request.UserId, request.IssueId);
52+
return Result.Fail<IssueDto>("Already voted", ResultErrorCode.Validation);
53+
}
54+
55+
issue.VotedBy.Add(request.UserId);
56+
issue.Votes = issue.VotedBy.Count;
57+
issue.DateModified = DateTime.UtcNow;
58+
59+
var result = await _repository.UpdateAsync(issue, cancellationToken);
60+
61+
if (result.Failure)
62+
{
63+
_logger.LogError("Failed to save vote on issue {IssueId}: {Error}", request.IssueId, result.Error);
64+
return Result.Fail<IssueDto>(result.Error ?? "Failed to save vote", result.ErrorCode);
65+
}
66+
67+
_logger.LogInformation("User {UserId} successfully voted on issue {IssueId}", request.UserId, request.IssueId);
68+
return Result.Ok(new IssueDto(result.Value!));
69+
}
70+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026. All rights reserved.
2+
3+
using Domain.Features.Issues.Commands;
4+
5+
namespace Domain.Features.Issues.Validators;
6+
7+
/// <summary>
8+
/// Validator for VoteIssueCommand.
9+
/// </summary>
10+
public sealed class VoteIssueCommandValidator : AbstractValidator<VoteIssueCommand>
11+
{
12+
public VoteIssueCommandValidator()
13+
{
14+
RuleFor(x => x.IssueId)
15+
.NotEmpty()
16+
.WithMessage("Issue ID is required");
17+
18+
RuleFor(x => x.UserId)
19+
.NotEmpty()
20+
.WithMessage("User ID is required")
21+
.Must(id => !string.IsNullOrWhiteSpace(id))
22+
.WithMessage("User ID must not be whitespace");
23+
}
24+
}
25+
26+
/// <summary>
27+
/// Validator for UnvoteIssueCommand.
28+
/// </summary>
29+
public sealed class UnvoteIssueCommandValidator : AbstractValidator<UnvoteIssueCommand>
30+
{
31+
public UnvoteIssueCommandValidator()
32+
{
33+
RuleFor(x => x.IssueId)
34+
.NotEmpty()
35+
.WithMessage("Issue ID is required");
36+
37+
RuleFor(x => x.UserId)
38+
.NotEmpty()
39+
.WithMessage("User ID is required")
40+
.Must(id => !string.IsNullOrWhiteSpace(id))
41+
.WithMessage("User ID must not be whitespace");
42+
}
43+
}

src/Domain/Mappers/IssueMapper.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ public static IssueDto ToDto(Issue? issue)
3737
UserMapper.ToDto(issue.ArchivedBy),
3838
issue.ApprovedForRelease,
3939
issue.Rejected,
40-
UserMapper.ToDto(issue.Assignee));
40+
UserMapper.ToDto(issue.Assignee),
41+
issue.Votes,
42+
issue.VotedBy);
4143
}
4244

4345
/// <summary>
@@ -62,7 +64,9 @@ public static Issue ToModel(IssueDto? dto)
6264
Archived = dto.Archived,
6365
ArchivedBy = UserMapper.ToInfo(dto.ArchivedBy),
6466
ApprovedForRelease = dto.ApprovedForRelease,
65-
Rejected = dto.Rejected
67+
Rejected = dto.Rejected,
68+
Votes = dto.Votes,
69+
VotedBy = [..dto.VotedBy]
6670
};
6771
}
6872

src/Domain/Models/Issue.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,20 @@ public class Issue
119119
/// <c>true</c> if rejected; otherwise, <c>false</c>.
120120
/// </value>
121121
public bool Rejected { get; set; }
122+
123+
/// <summary>
124+
/// Gets or sets the number of votes this issue has received.
125+
/// </summary>
126+
/// <value>
127+
/// The vote count.
128+
/// </value>
129+
public int Votes { get; set; } = 0;
130+
131+
/// <summary>
132+
/// Gets or sets the list of user IDs that have voted for this issue.
133+
/// </summary>
134+
/// <value>
135+
/// The collection of voter user IDs.
136+
/// </value>
137+
public List<string> VotedBy { get; set; } = [];
122138
}

src/Persistence.MongoDb/Configurations/IssueConfiguration.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,8 @@ public void Configure(EntityTypeBuilder<Issue> builder)
6868
// archival metadata belongs on the Status entity, not its embedded reference
6969
s.Ignore(st => st.ArchivedBy);
7070
});
71+
72+
builder.Property(i => i.Votes);
73+
builder.Property(i => i.VotedBy);
7174
}
7275
}

0 commit comments

Comments
 (0)