Skip to content
Closed
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
4 changes: 3 additions & 1 deletion src/Api/Data/Interfaces/IIssueRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ public interface IIssueRepository
/// <param name="pageSize">The number of items per page.</param>
/// <param name="searchTerm">Optional search term to filter by title or description.</param>
/// <param name="authorName">Optional author name to filter by.</param>
/// <param name="statusName">Optional status name to filter by.</param>
/// <param name="categoryName">Optional category name to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Result<(IReadOnlyList<IssueDto> Items, long Total)>> GetAllAsync(int page, int pageSize, string? searchTerm = null, string? authorName = null, CancellationToken cancellationToken = default);
Task<Result<(IReadOnlyList<IssueDto> Items, long Total)>> GetAllAsync(int page, int pageSize, string? searchTerm = null, string? authorName = null, string? statusName = null, string? categoryName = null, CancellationToken cancellationToken = default);

/// <summary>
/// Updates an existing issue in the database.
Expand Down
12 changes: 12 additions & 0 deletions src/Api/Data/IssueRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ public async Task<Result<IReadOnlyList<IssueDto>>> GetAllAsync(CancellationToken
int pageSize,
string? searchTerm = null,
string? authorName = null,
string? statusName = null,
string? categoryName = null,
CancellationToken cancellationToken = default)
{
var filterBuilder = Builders<Issue>.Filter;
Expand All @@ -94,6 +96,16 @@ public async Task<Result<IReadOnlyList<IssueDto>>> GetAllAsync(CancellationToken
filters.Add(filterBuilder.Regex(x => x.Author.Name, new BsonRegularExpression(authorName, "i")));
}

if (!string.IsNullOrWhiteSpace(statusName))
{
filters.Add(filterBuilder.Regex(x => x.Status.StatusName, new BsonRegularExpression(statusName, "i")));
}

if (!string.IsNullOrWhiteSpace(categoryName))
{
filters.Add(filterBuilder.Regex(x => x.Category.CategoryName, new BsonRegularExpression(categoryName, "i")));
}
Comment on lines +99 to +107
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StatusName/CategoryName filters are applied as MongoDB regular expressions using the raw user input. This can cause query failures if the input is not a valid regex pattern (e.g., unmatched brackets) and can also make queries more expensive than intended. Consider escaping user input (treating it as a literal) or using an equality match/collation-based case-insensitive comparison instead of passing user-provided regex patterns directly.

Copilot uses AI. Check for mistakes.

var filter = filterBuilder.And(filters);
var total = await _collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken);
var entities = await _collection
Expand Down
6 changes: 4 additions & 2 deletions src/Api/Handlers/Issues/IssueEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ public static IEndpointRouteBuilder MapIssueEndpoints(this IEndpointRouteBuilder
var group = app.MapGroup("/api/v1/issues").WithTags("Issues");

// List Issues (paginated)
group.MapGet("", async (int? page, int? pageSize, string? searchTerm, string? authorName, ListIssuesHandler handler) =>
group.MapGet("", async (int? page, int? pageSize, string? searchTerm, string? authorName, string? statusName, string? categoryName, ListIssuesHandler handler) =>
{
var query = new ListIssuesQuery
{
Page = page ?? 1,
PageSize = pageSize ?? 20,
SearchTerm = searchTerm,
AuthorName = authorName
AuthorName = authorName,
StatusName = statusName,
CategoryName = categoryName
};
var result = await handler.Handle(query);
return Results.Ok(result);
Expand Down
2 changes: 1 addition & 1 deletion src/Api/Handlers/Issues/ListIssuesHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public async Task<PaginatedResponse<IssueDto>> Handle(ListIssuesQuery query, Can
if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors);

var result = await _repository.GetAllAsync(query.Page, query.PageSize, query.SearchTerm, query.AuthorName, cancellationToken);
var result = await _repository.GetAllAsync(query.Page, query.PageSize, query.SearchTerm, query.AuthorName, query.StatusName, query.CategoryName, cancellationToken);
var (items, total) = result.Value;

return new PaginatedResponse<IssueDto>(items, total, query.Page, query.PageSize);
Expand Down
10 changes: 10 additions & 0 deletions src/Shared/Contracts/ListIssuesQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,14 @@ public record ListIssuesQuery
/// Gets or sets the author name for filtering by author.
/// </summary>
public string? AuthorName { get; init; }

/// <summary>
/// Gets or sets the status name for filtering by status.
/// </summary>
public string? StatusName { get; init; }

/// <summary>
/// Gets or sets the category name for filtering by category.
/// </summary>
public string? CategoryName { get; init; }
Comment on lines +35 to +44
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListIssuesQuery now supports StatusName and CategoryName filters, but ListIssuesQueryValidator currently only validates SearchTerm/AuthorName length. To keep validation consistent and avoid unbounded/expensive regex filters, add similar max-length rules for StatusName and CategoryName in ListIssuesQueryValidator.

Copilot uses AI. Check for mistakes.
}
14 changes: 12 additions & 2 deletions src/Web/Components/Features/Issues/IssueApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ public interface IIssueApiClient
/// <param name="pageSize">The number of items per page.</param>
/// <param name="searchTerm">Optional search term to filter by title or description.</param>
/// <param name="authorName">Optional author name to filter by.</param>
/// <param name="statusName">Optional status name to filter by.</param>
/// <param name="categoryName">Optional category name to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PaginatedResponse<IssueDto>> GetAllAsync(int page = 1, int pageSize = 20, string? searchTerm = null, string? authorName = null, CancellationToken cancellationToken = default);
Task<PaginatedResponse<IssueDto>> GetAllAsync(int page = 1, int pageSize = 20, string? searchTerm = null, string? authorName = null, string? statusName = null, string? categoryName = null, CancellationToken cancellationToken = default);
Comment on lines 19 to +23
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIssueApiClient.GetAllAsync added two new optional parameters (statusName/categoryName). Several existing test mocks and NSubstitute setups still target the old 5-parameter signature, which will break compilation (e.g., IssuesPageTests/AdminPageTests/ProfilePageTests in Web.Tests.Bunit). Update those mocks to include the new parameters (or switch to named arguments) so the test suite builds.

Copilot uses AI. Check for mistakes.

/// <summary>Gets an issue by its identifier.</summary>
Task<IssueDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
Expand All @@ -45,7 +47,7 @@ public class IssueApiClient : IIssueApiClient
public IssueApiClient(HttpClient httpClient) => _httpClient = httpClient;

/// <inheritdoc/>
public async Task<PaginatedResponse<IssueDto>> GetAllAsync(int page = 1, int pageSize = 20, string? searchTerm = null, string? authorName = null, CancellationToken cancellationToken = default)
public async Task<PaginatedResponse<IssueDto>> GetAllAsync(int page = 1, int pageSize = 20, string? searchTerm = null, string? authorName = null, string? statusName = null, string? categoryName = null, CancellationToken cancellationToken = default)
{
try
{
Expand All @@ -58,6 +60,14 @@ public async Task<PaginatedResponse<IssueDto>> GetAllAsync(int page = 1, int pag
{
url += $"&authorName={Uri.EscapeDataString(authorName)}";
}
if (!string.IsNullOrWhiteSpace(statusName))
{
url += $"&statusName={Uri.EscapeDataString(statusName)}";
}
if (!string.IsNullOrWhiteSpace(categoryName))
{
url += $"&categoryName={Uri.EscapeDataString(categoryName)}";
}

var result = await _httpClient.GetFromJsonAsync<PaginatedResponse<IssueDto>>(url, cancellationToken).ConfigureAwait(false);
return result ?? PaginatedResponse<IssueDto>.Empty;
Expand Down
2 changes: 1 addition & 1 deletion src/Web/Components/Features/Issues/IssuesPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
_currentPage = page;
try
{
var response = await IssueClient.GetAllAsync(page, 20);
var response = await IssueClient.GetAllAsync(page, 20, _searchTerm, null, _statusFilter, _categoryFilter);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR title/description mention adding an admin-only archive action to StatusesPage, but the changes in this PR appear to be adding status/category filtering for the Issues list and related API/query updates. If this PR is intended to implement the Statuses archive UI/API client, those changes (e.g., IStatusApiClient.ArchiveAsync and StatusesPage UI updates) are missing; otherwise the PR metadata should be updated to reflect the actual scope.

Copilot uses AI. Check for mistakes.
_issues = response.Items;
_totalPages = response.TotalPages;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/Api.Tests.Unit/Endpoints/IssueEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public async Task ListIssues_ReturnsOk()
// Arrange
IReadOnlyList<IssueDto> items = [];
_factory.IssueRepository
.GetAllAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.GetAllAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Result<(IReadOnlyList<IssueDto> Items, long Total)>.Ok((items, 0L)));

// Act
Expand Down
16 changes: 8 additions & 8 deletions tests/Api.Tests.Unit/Handlers/Issues/ListIssuesHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public async Task Handle_DefaultPagination_ReturnsFirstPageWithCorrectMetadata()
var query = new ListIssuesQuery { Page = 1, PageSize = 20 };

var issues = GenerateIssueDtos(20);
_repository.GetAllAsync(1, 20, null, null, Arg.Any<CancellationToken>())
_repository.GetAllAsync(1, 20, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<IssueDto>)issues, 42L));

// Act
Expand All @@ -54,7 +54,7 @@ public async Task Handle_SecondPage_ReturnsCorrectItems()
var query = new ListIssuesQuery { Page = 2, PageSize = 10 };

var issues = GenerateIssueDtos(10);
_repository.GetAllAsync(2, 10, null, null, Arg.Any<CancellationToken>())
_repository.GetAllAsync(2, 10, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<IssueDto>)issues, 42L));

// Act
Expand All @@ -75,7 +75,7 @@ public async Task Handle_LastPagePartialItems_ReturnsCorrectCount()
var query = new ListIssuesQuery { Page = 3, PageSize = 20 };

var issues = GenerateIssueDtos(2); // Last page has only 2 items
_repository.GetAllAsync(3, 20, null, null, Arg.Any<CancellationToken>())
_repository.GetAllAsync(3, 20, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<IssueDto>)issues, 42L));

// Act
Expand All @@ -95,7 +95,7 @@ public async Task Handle_EmptyResult_ReturnsEmptyList()
// Arrange
var query = new ListIssuesQuery { Page = 1, PageSize = 20 };

_repository.GetAllAsync(1, 20, null, null, Arg.Any<CancellationToken>())
_repository.GetAllAsync(1, 20, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<IssueDto>)new List<IssueDto>(), 0L));

// Act
Expand All @@ -115,7 +115,7 @@ public async Task Handle_PageExceedsTotalPages_ReturnsEmptyList()
// Arrange
var query = new ListIssuesQuery { Page = 10, PageSize = 20 };

_repository.GetAllAsync(10, 20, null, null, Arg.Any<CancellationToken>())
_repository.GetAllAsync(10, 20, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<IssueDto>)new List<IssueDto>(), 42L));

// Act
Expand Down Expand Up @@ -176,14 +176,14 @@ public async Task Handle_ExcludesArchivedIssues_ByDefault()
var query = new ListIssuesQuery { Page = 1, PageSize = 20 };

var issues = GenerateIssueDtos(10);
_repository.GetAllAsync(1, 20, null, null, Arg.Any<CancellationToken>())
_repository.GetAllAsync(1, 20, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<IssueDto>)issues, 10L));

// Act
var result = await _handler.Handle(query, CancellationToken.None);

// Assert
await _repository.Received(1).GetAllAsync(1, 20, null, null, Arg.Any<CancellationToken>());
await _repository.Received(1).GetAllAsync(1, 20, null, null, null, null, Arg.Any<CancellationToken>());
result.Items.Should().HaveCount(10);
}

Expand All @@ -201,7 +201,7 @@ public async Task Handle_OrdersByCreatedAtDescending_NewestFirst()
new(ObjectId.GenerateNewId(), "Issue 1", "Desc", DateTime.UtcNow.AddDays(-3), null, UserDto.Empty, CategoryDto.Empty, StatusDto.Empty, false, UserDto.Empty, false, false),
};

_repository.GetAllAsync(1, 3, null, null, Arg.Any<CancellationToken>())
_repository.GetAllAsync(1, 3, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<IssueDto>)orderedIssues, 3L));

// Act
Expand Down
Loading