diff --git a/application/account-management/Api/Endpoints/UserEndpoints.cs b/application/account-management/Api/Endpoints/UserEndpoints.cs index 106aa3fa4..f2ca00737 100644 --- a/application/account-management/Api/Endpoints/UserEndpoints.cs +++ b/application/account-management/Api/Endpoints/UserEndpoints.cs @@ -53,5 +53,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPut("/change-locale", async Task (ChangeLocaleCommand command, IMediator mediator) => await mediator.Send(command) ); + + group.MapGet("/summary", async Task> (IMediator mediator) + => await mediator.Send(new GetUserSummaryQuery()) + ).Produces(); } } diff --git a/application/account-management/Core/Features/Signups/Queries/IsSubdomainFree.cs b/application/account-management/Core/Features/Signups/Queries/IsSubdomainFree.cs index e636890ff..8d8071f0a 100644 --- a/application/account-management/Core/Features/Signups/Queries/IsSubdomainFree.cs +++ b/application/account-management/Core/Features/Signups/Queries/IsSubdomainFree.cs @@ -10,8 +10,8 @@ public sealed record IsSubdomainFreeQuery(string Subdomain) : IRequest> { - public async Task> Handle(IsSubdomainFreeQuery request, CancellationToken cancellationToken) + public async Task> Handle(IsSubdomainFreeQuery query, CancellationToken cancellationToken) { - return await tenantRepository.IsSubdomainFreeAsync(request.Subdomain, cancellationToken); + return await tenantRepository.IsSubdomainFreeAsync(query.Subdomain, cancellationToken); } } diff --git a/application/account-management/Core/Features/Tenants/Queries/GetTenant.cs b/application/account-management/Core/Features/Tenants/Queries/GetTenant.cs index 63d09f216..a268f27f5 100644 --- a/application/account-management/Core/Features/Tenants/Queries/GetTenant.cs +++ b/application/account-management/Core/Features/Tenants/Queries/GetTenant.cs @@ -15,9 +15,9 @@ public sealed record TenantResponse(TenantId Id, DateTimeOffset CreatedAt, DateT public sealed class GetTenantHandler(ITenantRepository tenantRepository) : IRequestHandler> { - public async Task> Handle(GetTenantQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetTenantQuery query, CancellationToken cancellationToken) { - var tenant = await tenantRepository.GetByIdAsync(request.Id, cancellationToken); - return tenant?.Adapt() ?? Result.NotFound($"Tenant with id '{request.Id}' not found."); + var tenant = await tenantRepository.GetByIdAsync(query.Id, cancellationToken); + return tenant?.Adapt() ?? Result.NotFound($"Tenant with id '{query.Id}' not found."); } } diff --git a/application/account-management/Core/Features/Users/Domain/UserRepository.cs b/application/account-management/Core/Features/Users/Domain/UserRepository.cs index 2c74fa11a..3776fd882 100644 --- a/application/account-management/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account-management/Core/Features/Users/Domain/UserRepository.cs @@ -18,6 +18,8 @@ public interface IUserRepository : ICrudRepository Task CountTenantUsersAsync(TenantId tenantId, CancellationToken cancellationToken); + Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken); + Task<(User[] Users, int TotalItems, int TotalPages)> Search( string? search, UserRole? userRole, @@ -73,6 +75,24 @@ public Task CountTenantUsersAsync(TenantId tenantId, CancellationToken canc return DbSet.CountAsync(u => u.TenantId == tenantId, cancellationToken); } + public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken) + { + var thirtyDaysAgo = TimeProvider.System.GetUtcNow().AddDays(-30); + + var summary = await DbSet + .GroupBy(_ => 1) // Group all records into a single group to calculate multiple COUNT aggregates in one query + .Select(g => new + { + TotalUsers = g.Count(), + ActiveUsers = g.Count(u => u.EmailConfirmed && u.ModifiedAt >= thirtyDaysAgo), + PendingUsers = g.Count(u => !u.EmailConfirmed) + } + ) + .SingleAsync(cancellationToken); + + return (summary.TotalUsers, summary.ActiveUsers, summary.PendingUsers); + } + public async Task<(User[] Users, int TotalItems, int TotalPages)> Search( string? search, UserRole? userRole, diff --git a/application/account-management/Core/Features/Users/Queries/GetUser.cs b/application/account-management/Core/Features/Users/Queries/GetUser.cs index d188783cc..a832da715 100644 --- a/application/account-management/Core/Features/Users/Queries/GetUser.cs +++ b/application/account-management/Core/Features/Users/Queries/GetUser.cs @@ -25,9 +25,9 @@ public sealed record UserResponse( public sealed class GetUserHandler(IUserRepository userRepository) : IRequestHandler> { - public async Task> Handle(GetUserQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetUserQuery query, CancellationToken cancellationToken) { - var user = await userRepository.GetByIdAsync(request.Id, cancellationToken); - return user?.Adapt() ?? Result.NotFound($"User with id '{request.Id}' not found."); + var user = await userRepository.GetByIdAsync(query.Id, cancellationToken); + return user?.Adapt() ?? Result.NotFound($"User with id '{query.Id}' not found."); } } diff --git a/application/account-management/Core/Features/Users/Queries/GetUserSummary.cs b/application/account-management/Core/Features/Users/Queries/GetUserSummary.cs new file mode 100644 index 000000000..edd01ebe1 --- /dev/null +++ b/application/account-management/Core/Features/Users/Queries/GetUserSummary.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Cqrs; + +namespace PlatformPlatform.AccountManagement.Features.Users.Queries; + +[PublicAPI] +public sealed record GetUserSummaryQuery : IRequest>; + +[PublicAPI] +public sealed record GetUserSummaryResponse(int TotalUsers, int ActiveUsers, int PendingUsers); + +public sealed class GetUserSummaryHandler(IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetUserSummaryQuery query, CancellationToken cancellationToken) + { + var (totalUsers, activeUsers, pendingUsers) = await userRepository.GetUserSummaryAsync(cancellationToken); + return new GetUserSummaryResponse(totalUsers, activeUsers, pendingUsers); + } +} diff --git a/application/account-management/WebApp/routes/admin/index.tsx b/application/account-management/WebApp/routes/admin/index.tsx index 785ef6a16..80c17a1ff 100644 --- a/application/account-management/WebApp/routes/admin/index.tsx +++ b/application/account-management/WebApp/routes/admin/index.tsx @@ -9,7 +9,7 @@ export const Route = createFileRoute("/admin/")({ }); export default function Home() { - const { data } = useApi("/api/account-management/users", { params: { query: { PageSize: 1 } } }); + const { data } = useApi("/api/account-management/users/summary", {}); return (
@@ -35,7 +35,7 @@ export default function Home() { Add more in the Users menu
- {data?.totalCount ?

{data?.totalCount}

:

-

} + {data?.totalUsers ?

{data.totalUsers}

:

-

}
@@ -46,7 +46,18 @@ export default function Home() { Active users in the past 30 days
- {data?.totalCount ?

{data?.totalCount}

:

-

} + {data?.activeUsers ?

{data.activeUsers}

:

-

} +
+ +
+
+ Invited Users +
+
+ Users who haven't confirmed their email +
+
+ {data?.pendingUsers ?

{data.pendingUsers}

:

-

}
diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 71b5fb502..380c965ac 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -698,6 +698,26 @@ } } } + }, + "/api/account-management/users/summary": { + "get": { + "tags": [ + "Users" + ], + "operationId": "GetApiAccountManagementUsersSummary", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUserSummaryResponse" + } + } + } + } + } + } } }, "components": { @@ -1152,6 +1172,24 @@ "type": "string" } } + }, + "GetUserSummaryResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "totalUsers": { + "type": "integer", + "format": "int32" + }, + "activeUsers": { + "type": "integer", + "format": "int32" + }, + "pendingUsers": { + "type": "integer", + "format": "int32" + } + } } } } diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 458b100f5..46fb2ccc9 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -183,6 +183,9 @@ msgstr "Inviter Brugere" msgid "Invite users and assign them roles. They will appear once they log in." msgstr "Inviter brugere og tildel dem roller. De vil blive vist, når de logger ind." +msgid "Invited Users" +msgstr "Inviterede brugere" + msgid "Last name" msgstr "Efternavn" @@ -334,6 +337,9 @@ msgstr "user@email.com" msgid "Users" msgstr "Brugere" +msgid "Users who haven't confirmed their email" +msgstr "Brugere, der ikke har bekræftet deres e-mail" + msgid "Verify" msgstr "Bekræft" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index b75c97aa2..f8477d032 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -183,6 +183,9 @@ msgstr "Invite Users" msgid "Invite users and assign them roles. They will appear once they log in." msgstr "Invite users and assign them roles. They will appear once they log in." +msgid "Invited Users" +msgstr "Invited Users" + msgid "Last name" msgstr "Last name" @@ -334,6 +337,9 @@ msgstr "user@email.com" msgid "Users" msgstr "Users" +msgid "Users who haven't confirmed their email" +msgstr "Users who haven't confirmed their email" + msgid "Verify" msgstr "Verify" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 29538a5e9..ba025ae6d 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -183,6 +183,9 @@ msgstr "Gebruikers uitnodigen" msgid "Invite users and assign them roles. They will appear once they log in." msgstr "Nodig gebruikers uit en wijs hen rollen toe. Ze verschijnen zodra ze inloggen." +msgid "Invited Users" +msgstr "Uitgenodigde gebruikers" + msgid "Last name" msgstr "Achternaam" @@ -334,6 +337,9 @@ msgstr "gebruiker@email.com" msgid "Users" msgstr "Gebruikers" +msgid "Users who haven't confirmed their email" +msgstr "Gebruikers die hun e-mail niet hebben bevestigd" + msgid "Verify" msgstr "Verifiëren" diff --git a/application/shared-webapp/infrastructure/auth/constants.ts b/application/shared-webapp/infrastructure/auth/constants.ts index 1d399eb90..12afeb908 100644 --- a/application/shared-webapp/infrastructure/auth/constants.ts +++ b/application/shared-webapp/infrastructure/auth/constants.ts @@ -13,4 +13,4 @@ export const loginPath = "/login"; /** * Path to the default page shown after successful login */ -export const loggedInPath = "/admin/users"; +export const loggedInPath = "/admin";