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
11 changes: 6 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
<GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.9.50" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.MongoDB" Version="13.2.4" />
<PackageVersion Include="Aspire.Hosting.MongoDB" Version="13.3.0" />
<PackageVersion Include="Asp.Versioning.Http" Version="10.0.0" />
<PackageVersion Include="Aspire.Hosting.NodeJs" Version="9.5.2" />
<PackageVersion Include="AspNetCore.HealthChecks.MongoDb" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="AspNetCore.Identity.MongoDbCore" Version="7.0.0" />
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.2" />
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.2" />
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.3" />
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.3" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="10.0.7" />
Expand Down Expand Up @@ -49,20 +49,21 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.11" />
<!-- Pin transitive Snappier (pulled in by MongoDB.Driver) to 1.3.1+; 1.0.0 has GHSA-pggp-6c3x-2xmx. -->
<PackageVersion Include="SharpCompress" Version="0.48.0" />
<PackageVersion Include="Snappier" Version="1.3.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.7" />
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
<PackageVersion Include="Terminal.Gui" Version="2.0.0-develop.5213" />
</ItemGroup>
<ItemGroup Label="Test packages">
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.2.4" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="Testcontainers.MongoDb" Version="4.11.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.11.0" />
<PackageVersion Include="Verify.SourceGenerators" Version="2.5.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.16.2" />
<PackageVersion Include="Verify.XunitV3" Version="31.16.3" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
</ItemGroup>
Expand Down
38 changes: 38 additions & 0 deletions src/GroundControl.Api/Features/Projects/Contracts/GroupProjects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace GroundControl.Api.Features.Projects.Contracts;

/// <summary>
/// Represents a single group section within a grouped project listing.
/// </summary>
internal sealed record GroupProjects
{
/// <summary>
/// Gets the group identifier.
/// </summary>
public required Guid Id { get; init; }

/// <summary>
/// Gets the group display name.
/// </summary>
public required string Name { get; init; }

/// <summary>
/// Gets the optional group description.
/// </summary>
public string? Description { get; init; }

/// <summary>
/// Gets the total number of projects in this group that match the current filter.
/// </summary>
public required long TotalCount { get; init; }

/// <summary>
/// Gets the first page of matching projects in this group, sorted by name ascending.
/// </summary>
public required IReadOnlyList<ProjectResponse> Projects { get; init; }

/// <summary>
/// Gets the cursor used to fetch the next page via <c>GET /api/projects?groupId=&amp;after=</c>, or
/// <see langword="null" /> when no more pages exist.
/// </summary>
public string? NextCursor { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;

namespace GroundControl.Api.Features.Projects.Contracts;

internal sealed class GroupedProjectsQuery
{
public const int DefaultPerGroup = 10;

public string? Search { get; init; }

[Range(1, 100)]
public int? PerGroup { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace GroundControl.Api.Features.Projects.Contracts;

/// <summary>
/// Represents a project listing partitioned by owning group, with a separate bucket for ungrouped projects.
/// </summary>
internal sealed record GroupedProjectsResponse
{
/// <summary>
/// Gets the per-group sections, sorted by group name ascending. Sections whose project list is empty
/// after applying the search filter are omitted.
/// </summary>
public required IReadOnlyList<GroupProjects> Groups { get; init; }

/// <summary>
/// Gets the bucket of projects that have no owning group, or <see langword="null" /> when no ungrouped
/// projects match the current filter.
/// </summary>
public UngroupedProjects? Ungrouped { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
using System.ComponentModel.DataAnnotations;
using GroundControl.Api.Shared.Pagination;
using GroundControl.Persistence.Contracts;
using ValidationContext = System.ComponentModel.DataAnnotations.ValidationContext;

namespace GroundControl.Api.Features.Projects.Contracts;

internal sealed class ProjectPaginationQuery : PaginationQuery
internal sealed class ProjectPaginationQuery : PaginationQuery, IValidatableObject
{
public Guid? GroupId { get; init; }

public bool? Ungrouped { get; init; }

public string? Search { get; init; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Ungrouped == true && GroupId.HasValue)
{
yield return new ValidationResult(
"Ungrouped cannot be combined with GroupId.",
[nameof(Ungrouped), nameof(GroupId)]);
}
}
}

internal static class ProjectPaginationQueryExtensions
Expand All @@ -20,6 +34,7 @@ internal static class ProjectPaginationQueryExtensions
SortField = query.SortField ?? PaginationQuery.DefaultSortField,
SortOrder = query.SortOrder ?? PaginationQuery.DefaultSortOrder,
GroupId = query.GroupId,
Ungrouped = query.Ungrouped ?? false,
Search = query.Search,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace GroundControl.Api.Features.Projects.Contracts;

/// <summary>
/// Represents the bucket of projects that have no owning group.
/// </summary>
internal sealed record UngroupedProjects
{
/// <summary>
/// Gets the total number of ungrouped projects that match the current filter.
/// </summary>
public required long TotalCount { get; init; }

/// <summary>
/// Gets the first page of matching ungrouped projects, sorted by name ascending.
/// </summary>
public required IReadOnlyList<ProjectResponse> Projects { get; init; }

/// <summary>
/// Gets the cursor used to fetch the next page via <c>GET /api/projects?ungrouped=true&amp;after=</c>,
/// or <see langword="null" /> when no more pages exist.
/// </summary>
public string? NextCursor { get; init; }
}
141 changes: 141 additions & 0 deletions src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using GroundControl.Api.Features.Projects.Contracts;
using GroundControl.Api.Shared.Security;
using GroundControl.Persistence.Contracts;
using GroundControl.Persistence.Stores;
using Microsoft.AspNetCore.Mvc;

namespace GroundControl.Api.Features.Projects;

internal sealed class ListGroupedProjectsHandler : IEndpointHandler
{
private const int MaxGroups = 10;

private readonly IGroupStore _groupStore;
private readonly IProjectStore _projectStore;
private readonly ILogger<ListGroupedProjectsHandler> _logger;

public ListGroupedProjectsHandler(IGroupStore groupStore, IProjectStore projectStore, ILogger<ListGroupedProjectsHandler> logger)
{
_groupStore = groupStore ?? throw new ArgumentNullException(nameof(groupStore));
_projectStore = projectStore ?? throw new ArgumentNullException(nameof(projectStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

public static void Endpoint(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/grouped", async (
[AsParameters] GroupedProjectsQuery query,
[FromServices] ListGroupedProjectsHandler handler,
CancellationToken cancellationToken = default) => await handler.HandleAsync(query, cancellationToken))
.RequireAuthorization(Permissions.ProjectsRead)
.WithSummary("List projects grouped by owning group")
.WithDescription(
"Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. "
+ "Sections whose project list is empty after applying the search filter are omitted. "
+ "Use the returned per-section cursor with GET /api/projects to fetch the next page.")
.Produces<GroupedProjectsResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.WithName(nameof(ListGroupedProjectsHandler));
}

private async Task<IResult> HandleAsync(GroupedProjectsQuery query, CancellationToken cancellationToken = default)
{
var perGroup = query.PerGroup ?? GroupedProjectsQuery.DefaultPerGroup;
var search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search;

var groupsPage = await _groupStore.ListAsync(
new ListQuery
{
Limit = MaxGroups,
SortField = "name",
SortOrder = "asc"
},
cancellationToken);

if (groupsPage.NextCursor is not null)
{
_logger.LogGroupsSoftCapHit(MaxGroups, groupsPage.TotalCount);
}

var groupTasks = groupsPage.Items
.Select(group => LoadGroupSectionAsync(group, search, perGroup, cancellationToken))
.ToList();

var ungroupedTask = LoadUngroupedSectionAsync(search, perGroup, cancellationToken);

var groupResults = await Task.WhenAll(groupTasks);
var ungrouped = await ungroupedTask;

var sections = groupResults
.Where(section => section is not null)
.Select(section => section!)
.ToList();

return TypedResults.Ok(new GroupedProjectsResponse
{
Groups = sections,
Ungrouped = ungrouped
});
}

private async Task<GroupProjects?> LoadGroupSectionAsync(Group group, string? search, int perGroup, CancellationToken cancellationToken)
{
var page = await _projectStore.ListAsync(
new ProjectListQuery
{
Limit = perGroup,
SortField = "name",
SortOrder = "asc",
GroupId = group.Id,
Search = search
},
cancellationToken);

if (page.TotalCount == 0)
{
return null;
}

return new GroupProjects
{
Id = group.Id,
Name = group.Name,
Description = group.Description,
TotalCount = page.TotalCount,
Projects = page.Items.Select(ProjectResponse.From).ToList(),
NextCursor = page.NextCursor
};
}

private async Task<UngroupedProjects?> LoadUngroupedSectionAsync(string? search, int perGroup, CancellationToken cancellationToken)
{
var page = await _projectStore.ListAsync(
new ProjectListQuery
{
Limit = perGroup,
SortField = "name",
SortOrder = "asc",
Ungrouped = true,
Search = search
},
cancellationToken);

if (page.TotalCount == 0)
{
return null;
}

return new UngroupedProjects
{
TotalCount = page.TotalCount,
Projects = page.Items.Select(ProjectResponse.From).ToList(),
NextCursor = page.NextCursor
};
}
}

internal static partial class ListGroupedProjectsHandlerLogs
{
[LoggerMessage(1, LogLevel.Warning, "Grouped projects endpoint hit the soft cap of {Cap} groups (total groups: {TotalCount}); sections beyond the cap are omitted from the response.")]
public static partial void LogGroupsSoftCapHit(this ILogger<ListGroupedProjectsHandler> logger, int cap, long totalCount);
}
2 changes: 2 additions & 0 deletions src/GroundControl.Api/Features/Projects/ProjectsModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public void OnServiceConfiguration(WebApplicationBuilder builder)
builder.Services.AddTransient<CreateProjectHandler>();
builder.Services.AddTransient<GetProjectHandler>();
builder.Services.AddTransient<ListProjectsHandler>();
builder.Services.AddTransient<ListGroupedProjectsHandler>();
builder.Services.AddTransient<UpdateProjectHandler>();
builder.Services.AddTransient<DeleteProjectHandler>();
builder.Services.AddTransient<AddProjectTemplateHandler>();
Expand All @@ -30,6 +31,7 @@ public void OnApplicationConfiguration(WebApplication app)
CreateProjectHandler.Endpoint(group);
GetProjectHandler.Endpoint(group);
ListProjectsHandler.Endpoint(group);
ListGroupedProjectsHandler.Endpoint(group);
UpdateProjectHandler.Endpoint(group);
DeleteProjectHandler.Endpoint(group);
AddProjectTemplateHandler.Endpoint(group);
Expand Down
Loading
Loading