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
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);
Comment on lines 42 to +48
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.

IIssueRepository.GetAllAsync signature change adds new parameters; any existing repository stubs/mocks using the old 5-arg overload will no longer compile. Update those call sites (especially unit/integration tests) to pass statusName and categoryName (or use named arguments) to match the new contract.

Copilot uses AI. Check for mistakes.

/// <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")));
}

var filter = filterBuilder.And(filters);
var total = await _collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken);
var entities = await _collection
Expand Down
2 changes: 2 additions & 0 deletions src/Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ public static IServiceCollection AddHandlers(this IServiceCollection services)
services.AddScoped<GetStatusHandler>();
services.AddScoped<ListStatusesHandler>();
services.AddScoped<UpdateStatusHandler>();
services.AddScoped<DeleteStatusHandler>();
services.AddScoped<CreateCategoryHandler>();
services.AddScoped<GetCategoryHandler>();
services.AddScoped<ListCategoriesHandler>();
services.AddScoped<UpdateCategoryHandler>();
services.AddScoped<DeleteCategoryHandler>();
services.AddScoped<CreateCommentHandler>();
services.AddScoped<GetCommentHandler>();
services.AddScoped<ListCommentsHandler>();
Expand Down
14 changes: 14 additions & 0 deletions src/Api/Handlers/Categories/CategoryEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ public static IEndpointRouteBuilder MapCategoryEndpoints(this IEndpointRouteBuil
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();

group.MapDelete("{id}", async (string id, DeleteCategoryHandler handler) =>
{
if (!ObjectId.TryParse(id, out var objectId))
return Results.BadRequest("Invalid ID format");
var command = new DeleteCategoryCommand { Id = objectId };
var result = await handler.Handle(command);
return result.Success ? Results.NoContent() : Results.NotFound();
})
Comment on lines +69 to +76
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.

Similar to statuses, the non-paginated category list path uses CategoryRepository.GetAllAsync() which returns archived categories. If archived categories should be hidden by default after soft-delete, update the list handler/repository to filter Archived=false so the new DELETE behavior is observable.

Copilot uses AI. Check for mistakes.
.WithName("DeleteCategory")
.WithSummary("Delete (archive) a category")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
Comment on lines +69 to +81
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.

Category DELETE endpoint is currently protected with RequireAuthorization() (any authenticated user). If deletion is intended to be admin-only (as described for the analogous Status endpoint), enforce the appropriate admin policy/role requirement here as well.

Copilot uses AI. Check for mistakes.

return app;
}
}
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
4 changes: 2 additions & 2 deletions src/Api/Handlers/Statuses/DeleteStatusHandler.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// =======================================================
// =============================================
// Copyright (c) 2026. All rights reserved.
// File Name : DeleteStatusHandler.cs
// Company : mpaulosky
// Author : Matthew Paulosky
// Solution Name : IssueManager
// Project Name : Api
// =======================================================
// =============================================

namespace Api.Handlers.Statuses;

Expand Down
14 changes: 14 additions & 0 deletions src/Api/Handlers/Statuses/StatusEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ public static IEndpointRouteBuilder MapStatusEndpoints(this IEndpointRouteBuilde
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();

group.MapDelete("{id}", async (string id, DeleteStatusHandler handler) =>
{
if (!ObjectId.TryParse(id, out var objectId))
return Results.BadRequest("Invalid ID format");
var command = new DeleteStatusCommand { Id = objectId };
var result = await handler.Handle(command);
return result.Success ? Results.NoContent() : Results.NotFound();
Comment on lines +73 to +79
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.

After archiving a status, ListStatusesHandler/StatusRepository.GetAllAsync() still returns archived statuses (it uses an unfiltered Find(_ => true)). If the intended behavior is that archived statuses are hidden by default, adjust the list handler/repository to filter Archived=false so DELETE has the expected effect for consumers.

Copilot uses AI. Check for mistakes.
})
.WithName("DeleteStatus")
.WithSummary("Delete (archive) a status")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
Comment on lines +77 to +84
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.

The new DELETE status endpoint returns 404 for any handler failure, which can mask non-NotFound errors (e.g., validation/storage errors). Consider mapping ResultErrorCode (NotFound vs Validation vs other) to appropriate HTTP responses (404/400/500) to avoid misleading clients.

Suggested change
var command = new DeleteStatusCommand { Id = objectId };
var result = await handler.Handle(command);
return result.Success ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteStatus")
.WithSummary("Delete (archive) a status")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
var command = new DeleteStatusCommand { Id = objectId };
var result = await handler.Handle(command);
if (result.Success)
return Results.NoContent();
return result.ErrorCode switch
{
ResultErrorCode.NotFound => Results.NotFound(),
ResultErrorCode.Validation => Results.BadRequest(result.Error),
_ => Results.Problem(
detail: result.Error,
statusCode: StatusCodes.Status500InternalServerError)
};
})
.WithName("DeleteStatus")
.WithSummary("Delete (archive) a status")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status500InternalServerError)

Copilot uses AI. Check for mistakes.
.RequireAuthorization();

Comment on lines +81 to +86
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.

This endpoint is described as “Admin only” in the PR metadata, but it currently uses RequireAuthorization() (any authenticated user). If admin restriction is required, enforce the appropriate policy/role requirement here (and ensure it’s registered) so DELETE is actually admin-scoped.

Copilot uses AI. Check for mistakes.
return app;
}
}
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.

These new filter fields (StatusName/CategoryName) aren’t validated in ListIssuesQueryValidator, while SearchTerm/AuthorName are. Add similar max-length (and possibly allowed-character) validation rules so query input constraints stay consistent and avoid excessively large regex filters being passed down to the repository.

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 17 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.

Changing IIssueApiClient.GetAllAsync’s parameter list will break existing NSubstitute setups/call sites that use positional arguments (notably in tests). Update downstream mocks/calls to include the new statusName/categoryName parameters (or switch those invocations to named arguments) so the solution compiles cleanly.

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);
_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
Loading