diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs index 5d9a8a78..34940fb8 100644 --- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs +++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs @@ -10788,7 +10788,7 @@ public string BaseUrl /// /// Tenant users. /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetTenantUsersAsync(int? pageNumber = null, int? pageSize = null, System.Guid? userId = null, string email = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task GetTenantUsersAsync(int? pageNumber = null, int? pageSize = null, System.Guid? userId = null, string email = null, string searchTerm = null, string role = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -10820,6 +10820,14 @@ public string BaseUrl { urlBuilder_.Append(System.Uri.EscapeDataString("email")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(email, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (searchTerm != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("searchTerm")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(searchTerm, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (role != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("role")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(role, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs index d1ed4eeb..3c6b9ebf 100644 --- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs +++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs @@ -721,7 +721,7 @@ public partial interface IUsersClient /// /// Tenant users. /// A server side error occurred. - System.Threading.Tasks.Task GetTenantUsersAsync(int? pageNumber = null, int? pageSize = null, System.Guid? userId = null, string email = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task GetTenantUsersAsync(int? pageNumber = null, int? pageSize = null, System.Guid? userId = null, string email = null, string searchTerm = null, string role = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json index e952094e..82280b8f 100644 --- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json +++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json @@ -5462,6 +5462,24 @@ "nullable": true }, "x-position": 4 + }, + { + "name": "searchTerm", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 5 + }, + { + "name": "role", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 6 } ], "responses": { diff --git a/src/GovUK.Dfe.FlexForms.Api/Controllers/UsersController.cs b/src/GovUK.Dfe.FlexForms.Api/Controllers/UsersController.cs index 5ee4168a..3bfffb9d 100644 --- a/src/GovUK.Dfe.FlexForms.Api/Controllers/UsersController.cs +++ b/src/GovUK.Dfe.FlexForms.Api/Controllers/UsersController.cs @@ -137,6 +137,8 @@ public async Task>> GetTenantUsersAsync( [FromQuery] int? pageSize, [FromQuery] Guid? userId, [FromQuery] string? email, + [FromQuery] string? searchTerm, + [FromQuery] string? role, CancellationToken cancellationToken) { var result = await sender.Send( @@ -144,7 +146,9 @@ public async Task>> GetTenantUsersAsync( pageNumber ?? 1, pageSize ?? GetTenantUsersQuery.DefaultPageSize, userId, - email), + email, + searchTerm, + role), cancellationToken); if (!result.IsSuccess) diff --git a/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetActiveTenantMembershipsForDirectoryQueryObject.cs b/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetActiveTenantMembershipsForDirectoryQueryObject.cs index 2b0de10c..9fe4854e 100644 --- a/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetActiveTenantMembershipsForDirectoryQueryObject.cs +++ b/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetActiveTenantMembershipsForDirectoryQueryObject.cs @@ -12,7 +12,9 @@ namespace GovUK.Dfe.FlexForms.Application.TenantMemberships.QueryObjects; public sealed class GetActiveTenantMembershipsForDirectoryQueryObject( Guid tenantId, UserId? userId = null, - string? email = null) + string? email = null, + string? searchTerm = null, + string? role = null) : IQueryObject { public IQueryable Apply(IQueryable query) @@ -32,6 +34,12 @@ public IQueryable Apply(IQueryable query) query = query.Where(m => m.User != null && m.User.Email.ToLower() == normalized); } + if (!string.IsNullOrWhiteSpace(searchTerm)) + query = new GetTenantMembershipsBySearchTermQueryObject(searchTerm).Apply(query); + + if (!string.IsNullOrWhiteSpace(role)) + query = new GetTenantMembershipsByRoleNameQueryObject(role).Apply(query); + return query .OrderBy(m => m.User!.Name) .ThenBy(m => m.User!.Email); diff --git a/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetTenantMembershipsByRoleNameQueryObject.cs b/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetTenantMembershipsByRoleNameQueryObject.cs new file mode 100644 index 00000000..d5c4e9a5 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetTenantMembershipsByRoleNameQueryObject.cs @@ -0,0 +1,16 @@ +using GovUK.Dfe.FlexForms.Application.Common.QueriesObjects; +using GovUK.Dfe.FlexForms.Domain.Entities; + +namespace GovUK.Dfe.FlexForms.Application.TenantMemberships.QueryObjects; + +/// +/// Restricts memberships to a single role, matched on the role name assigned within the tenant. +/// +public sealed class GetTenantMembershipsByRoleNameQueryObject(string roleName) + : IQueryObject +{ + private readonly string _roleName = roleName.Trim().ToLowerInvariant(); + + public IQueryable Apply(IQueryable query) => + query.Where(m => m.Role != null && m.Role.Name.ToLower() == _roleName); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetTenantMembershipsBySearchTermQueryObject.cs b/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetTenantMembershipsBySearchTermQueryObject.cs new file mode 100644 index 00000000..5737f93a --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/TenantMemberships/QueryObjects/GetTenantMembershipsBySearchTermQueryObject.cs @@ -0,0 +1,19 @@ +using GovUK.Dfe.FlexForms.Application.Common.QueriesObjects; +using GovUK.Dfe.FlexForms.Domain.Entities; + +namespace GovUK.Dfe.FlexForms.Application.TenantMemberships.QueryObjects; + +/// +/// Free-text filter matching part of the member's name or email address. +/// +public sealed class GetTenantMembershipsBySearchTermQueryObject(string searchTerm) + : IQueryObject +{ + private readonly string _searchTerm = searchTerm.Trim().ToLowerInvariant(); + + public IQueryable Apply(IQueryable query) => + query.Where(m => + m.User != null + && (m.User.Name.ToLower().Contains(_searchTerm) + || m.User.Email.ToLower().Contains(_searchTerm))); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryHandler.cs index 55e216d4..bf2d4be8 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryHandler.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryHandler.cs @@ -23,12 +23,16 @@ public sealed record GetTenantUsersQuery( int PageNumber = 1, int PageSize = 10, Guid? UserId = null, - string? Email = null) + string? Email = null, + string? SearchTerm = null, + string? Role = null) : IRequest>> { public const int DefaultPageSize = 10; public const int MaxPageSize = 100; + + public const int MaxSearchTermLength = 256; } /// @@ -60,7 +64,9 @@ public async Task>> Handle( var membershipQuery = new GetActiveTenantMembershipsForDirectoryQueryObject( currentTenant.Id, request.UserId is null ? null : new UserId(request.UserId.Value), - request.Email) + request.Email, + request.SearchTerm, + request.Role) .Apply(membershipRepository.Query()); var totalCount = await membershipQuery.CountAsync(cancellationToken); diff --git a/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryValidator.cs b/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryValidator.cs index b2f8cf05..fadf3c10 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryValidator.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Users/Queries/GetTenantUsersQueryValidator.cs @@ -17,5 +17,13 @@ public GetTenantUsersQueryValidator() RuleFor(x => x.Email!) .EmailAddress(); }); + + RuleFor(x => x.SearchTerm!) + .MaximumLength(GetTenantUsersQuery.MaxSearchTermLength) + .When(x => !string.IsNullOrWhiteSpace(x.SearchTerm)); + + RuleFor(x => x.Role!) + .MaximumLength(50) + .When(x => !string.IsNullOrWhiteSpace(x.Role)); } } diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs index a96600f0..6bce9c28 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs @@ -277,6 +277,10 @@ private static void ConfigureTenantMembership(EntityTypeBuilder e.UserId) .HasDatabaseName("IX_TenantMemberships_UserId"); + // Serves the User Manager directory listing, which always scopes to an active tenant + // membership and optionally narrows to a single role. + b.HasIndex(e => new { e.TenantId, e.IsActive, e.RoleId }) + .HasDatabaseName("IX_TenantMemberships_TenantId_IsActive_RoleId"); if (useTemporal) { @@ -383,6 +387,9 @@ private static void ConfigureUser(EntityTypeBuilder b, bool useTemporal) .IsUnicode(false); b.HasIndex(u => u.ExternalProviderId).IsUnique(); b.HasIndex(e => e.Email).IsUnique(); + // The tenant user directory orders by name, so keep a sorted copy available. + b.HasIndex(e => e.Name) + .HasDatabaseName("IX_Users_Name"); b.HasOne(e => e.Role) .WithMany() .HasForeignKey(e => e.RoleId); diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260910082328_AddTenantUserDirectoryFilterIndexes.Designer.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260910082328_AddTenantUserDirectoryFilterIndexes.Designer.cs new file mode 100644 index 00000000..88d57e81 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260910082328_AddTenantUserDirectoryFilterIndexes.Designer.cs @@ -0,0 +1,1035 @@ +// +using System; +using GovUK.Dfe.FlexForms.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GovUK.Dfe.FlexForms.Infrastructure.Migrations +{ + [DbContext(typeof(ExternalApplicationsContext))] + [Migration("20260910082328_AddTenantUserDirectoryFilterIndexes")] + partial class AddTenantUserDirectoryFilterIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("ApplicationReference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("ApplicationReference"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeletedBy"); + + b.Property("DeletedOn") + .HasColumnType("datetime2") + .HasColumnName("DeletedOn"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("Status"); + + b.Property("TemplateVersionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateVersionId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationReference") + .IsUnique() + .HasDatabaseName("IX_Applications_ApplicationReference"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("IX_Applications_CreatedOn"); + + b.HasIndex("DeletedBy"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("TemplateVersionId") + .HasDatabaseName("IX_Applications_TemplateVersionId"); + + b.HasIndex("Status", "LastModifiedOn") + .HasDatabaseName("IX_Applications_Status_LastModifiedOn"); + + b.ToTable("Applications", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Applications", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.ApplicationResponse", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("ResponseId"); + + b.Property("ApplicationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ResponseBody"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("ApplicationId", "CreatedOn") + .IsDescending(false, true) + .HasDatabaseName("IX_ApplicationResponses_ApplicationId_CreatedOn"); + + b.ToTable("ApplicationResponses", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.CustomApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("CustomApplicationStatusId"); + + b.Property("ApplicationStatus") + .HasColumnType("int") + .HasColumnName("ApplicationStatus"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("Label"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateId"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("TemplateId", "ApplicationStatus") + .IsUnique() + .HasDatabaseName("IX_CustomApplicationStatuses_TemplateId_ApplicationStatus"); + + b.ToTable("CustomApplicationStatuses", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_CustomApplicationStatuses", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.File", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("FileId"); + + b.Property("ApplicationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("Description"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("FileName"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("FileSize"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("Name"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("OriginalFileName"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("Path"); + + b.Property("UploadedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("UploadedBy"); + + b.Property("UploadedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("UploadedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("ValidatedOn") + .HasColumnType("datetime2") + .HasColumnName("ValidatedOn"); + + b.Property("ValidationMessage") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("ValidationMessage"); + + b.Property("ValidationSource") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("ValidationSource"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("ValidationStatus"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId") + .HasDatabaseName("IX_Files_ApplicationId"); + + b.HasIndex("UploadedBy"); + + b.HasIndex("ApplicationId", "FileName") + .HasDatabaseName("IX_Files_ApplicationId_FileName"); + + b.HasIndex("Path", "FileName") + .HasDatabaseName("IX_Files_Path_FileName"); + + b.ToTable("Files", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("PermissionId"); + + b.Property("AccessType") + .HasColumnType("tinyint") + .HasColumnName("AccessType"); + + b.Property("ApplicationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("GrantedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("GrantedBy"); + + b.Property("GrantedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("GrantedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("ResourceKey"); + + b.Property("ResourceType") + .HasColumnType("tinyint") + .HasColumnName("ResourceType"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.HasKey("Id"); + + b.HasIndex("GrantedBy"); + + b.HasIndex("ApplicationId", "ResourceType") + .HasDatabaseName("IX_Permissions_ApplicationId_ResourceType"); + + b.HasIndex("UserId", "ResourceType", "ApplicationId") + .HasDatabaseName("IX_Permissions_UserId_ResourceType_ApplicationId"); + + b.ToTable("Permissions", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.Property("IsSystem") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsSystem"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("Name"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_Roles_TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique() + .HasDatabaseName("IX_Roles_TenantId_Name") + .HasFilter("[TenantId] IS NOT NULL"); + + b.ToTable("Roles", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Roles", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("RolePermissionId"); + + b.Property("AccessType") + .HasColumnType("int") + .HasColumnName("AccessType"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("ResourceKey"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("ResourceType"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "ResourceType", "ResourceKey", "AccessType") + .IsUnique() + .HasDatabaseName("IX_RolePermissions_Role_Resource_Access"); + + b.ToTable("RolePermissions", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("IsLive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsLive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Name"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique() + .HasDatabaseName("IX_Templates_TenantId_Name") + .HasFilter("[TenantId] IS NOT NULL"); + + b.ToTable("Templates", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Templates", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateVersionId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("JsonSchema") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("JsonSchema"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateId"); + + b.Property("VersionNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("VersionNumber"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("TemplateId", "CreatedOn") + .IsDescending(false, true) + .HasDatabaseName("IX_TemplateVersions_TemplateId_CreatedOn"); + + b.ToTable("TemplateVersions", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantAccessAudit", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ActorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Details") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("RoleName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SubjectEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("SubjectUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OccurredAtUtc") + .HasDatabaseName("IX_TenantAccessAudits_TenantId_OccurredAtUtc"); + + b.HasIndex("TenantId", "SubjectEmail") + .HasDatabaseName("IX_TenantAccessAudits_TenantId_SubjectEmail"); + + b.ToTable("TenantAccessAudits", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantMembership", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantMembershipId"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true) + .HasColumnName("IsActive"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_TenantMemberships_UserId"); + + b.HasIndex("TenantId", "UserId") + .IsUnique() + .HasDatabaseName("IX_TenantMemberships_TenantId_UserId"); + + b.HasIndex("TenantId", "IsActive", "RoleId") + .HasDatabaseName("IX_TenantMemberships_TenantId_IsActive_RoleId"); + + b.ToTable("TenantMemberships", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_TenantMemberships", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("Email"); + + b.Property("ExternalProviderId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Name"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ExternalProviderId") + .IsUnique() + .HasFilter("[ExternalProviderId] IS NOT NULL"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("Name") + .HasDatabaseName("IX_Users_Name"); + + b.HasIndex("RoleId"); + + b.ToTable("Users", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Users", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "DeletedByUser") + .WithMany() + .HasForeignKey("DeletedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", "TemplateVersion") + .WithMany() + .HasForeignKey("TemplateVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("DeletedByUser"); + + b.Navigation("LastModifiedByUser"); + + b.Navigation("TemplateVersion"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.ApplicationResponse", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application") + .WithMany("Responses") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Application"); + + b.Navigation("CreatedByUser"); + + b.Navigation("LastModifiedByUser"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.CustomApplicationStatus", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.File", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application") + .WithMany("Files") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "UploadedByUser") + .WithMany("Files") + .HasForeignKey("UploadedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("UploadedByUser"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Permission", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application") + .WithMany() + .HasForeignKey("ApplicationId"); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "GrantedByUser") + .WithMany() + .HasForeignKey("GrantedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User") + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("GrantedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.RolePermission", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template") + .WithMany("TemplateVersions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("LastModifiedByUser"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantMembership", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("LastModifiedByUser"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b => + { + b.Navigation("Files"); + + b.Navigation("Responses"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b => + { + b.Navigation("TemplateVersions"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b => + { + b.Navigation("Files"); + + b.Navigation("Permissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260910082328_AddTenantUserDirectoryFilterIndexes.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260910082328_AddTenantUserDirectoryFilterIndexes.cs new file mode 100644 index 00000000..e2f111eb --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260910082328_AddTenantUserDirectoryFilterIndexes.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GovUK.Dfe.FlexForms.Infrastructure.Migrations +{ + /// + public partial class AddTenantUserDirectoryFilterIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Users_Name", + schema: "ea", + table: "Users", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_TenantMemberships_TenantId_IsActive_RoleId", + schema: "ea", + table: "TenantMemberships", + columns: new[] { "TenantId", "IsActive", "RoleId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_Name", + schema: "ea", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_TenantMemberships_TenantId_IsActive_RoleId", + schema: "ea", + table: "TenantMemberships"); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs index af9ceb19..c6dbfa9b 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs @@ -684,6 +684,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("IX_TenantMemberships_TenantId_UserId"); + b.HasIndex("TenantId", "IsActive", "RoleId") + .HasDatabaseName("IX_TenantMemberships_TenantId_IsActive_RoleId"); + b.ToTable("TenantMemberships", "ea"); b.ToTable(tb => tb.IsTemporal(ttb => @@ -767,6 +770,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("LastModifiedBy"); + b.HasIndex("Name") + .HasDatabaseName("IX_Users_Name"); + b.HasIndex("RoleId"); b.ToTable("Users", "ea"); diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Users/GetTenantUsersQueryHandlerTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Users/GetTenantUsersQueryHandlerTests.cs index 47ccc554..79336a8b 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Users/GetTenantUsersQueryHandlerTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Users/GetTenantUsersQueryHandlerTests.cs @@ -163,6 +163,62 @@ public async Task Handle_ShouldFilterByUserId() Assert.Equal(1, result.Value.TotalCount); } + [Fact] + public async Task Handle_ShouldFilterBySearchTerm_AndPageTheFilteredSetOnly() + { + var role = Role.CreateForTenant(TenantId, RoleNames.User, true); + var matches = Enumerable.Range(1, 3) + .Select(i => CreateUser($"Brown {i}", $"brown{i}@example.test", role.Id!)) + .ToList(); + var nonMatches = Enumerable.Range(1, 9) + .Select(i => CreateUser($"Singh {i}", $"singh{i}@example.test", role.Id!)) + .ToList(); + + var membershipRepository = Substitute.For>(); + membershipRepository.Query().Returns(matches.Concat(nonMatches) + .Select(u => CreateMembership(TenantId, u, role)) + .AsQueryable() + .BuildMock()); + + var handler = CreateHandler(membershipRepository: membershipRepository); + + var result = await handler.Handle( + new GetTenantUsersQuery(PageNumber: 1, PageSize: 10, SearchTerm: "brown"), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(3, result.Value!.TotalCount); + Assert.Equal(1, result.Value.TotalPages); + Assert.All(result.Value.Items, item => Assert.StartsWith("Brown", item.Name)); + } + + [Fact] + public async Task Handle_ShouldFilterByRole() + { + var adminRole = Role.CreateForTenant(TenantId, RoleNames.Admin, true); + var userRole = Role.CreateForTenant(TenantId, RoleNames.User, true); + var admin = CreateUser("Ada", "ada@example.test", adminRole.Id!); + var member = CreateUser("Bob", "bob@example.test", userRole.Id!); + + var membershipRepository = Substitute.For>(); + membershipRepository.Query().Returns(new[] + { + CreateMembership(TenantId, admin, adminRole), + CreateMembership(TenantId, member, userRole) + }.AsQueryable().BuildMock()); + + var handler = CreateHandler(membershipRepository: membershipRepository); + + var result = await handler.Handle( + new GetTenantUsersQuery(PageNumber: 1, PageSize: 10, Role: RoleNames.Admin), + CancellationToken.None); + + var item = Assert.Single(result.Value!.Items); + Assert.Equal(admin.Id!.Value, item.UserId); + Assert.Equal(RoleNames.Admin, item.Role); + Assert.Equal(1, result.Value.TotalCount); + } + private static GetTenantUsersQueryHandler CreateHandler( IPermissionCheckerService? permissionChecker = null, ITenantContextAccessor? tenantContext = null, diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryObjects/TenantMemberships/GetActiveTenantMembershipsForDirectoryQueryObjectTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryObjects/TenantMemberships/GetActiveTenantMembershipsForDirectoryQueryObjectTests.cs index 91f2b7b3..c885269d 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryObjects/TenantMemberships/GetActiveTenantMembershipsForDirectoryQueryObjectTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryObjects/TenantMemberships/GetActiveTenantMembershipsForDirectoryQueryObjectTests.cs @@ -79,6 +79,76 @@ public void Apply_ShouldFilterByEmail_IgnoringCase() Assert.Equal("keep@example.test", membership.User!.Email); } + [Theory] + [InlineData("cat", "Catriona Brown")] + [InlineData("BROWN", "Catriona Brown")] + [InlineData("dev@", "Devi Rao")] + public void Apply_ShouldFilterBySearchTerm_AcrossNameAndEmail(string searchTerm, string expectedName) + { + var tenantId = Guid.NewGuid(); + var role = Role.CreateForTenant(tenantId, RoleNames.User, true); + var catriona = CreateUser("Catriona Brown", "c.brown@example.test", role.Id!); + var devi = CreateUser("Devi Rao", "dev@example.test", role.Id!); + + var result = new GetActiveTenantMembershipsForDirectoryQueryObject(tenantId, searchTerm: searchTerm) + .Apply(new[] + { + CreateMembership(tenantId, catriona, role, active: true), + CreateMembership(tenantId, devi, role, active: true) + }.AsQueryable().BuildMock()) + .ToList(); + + var membership = Assert.Single(result); + Assert.Equal(expectedName, membership.User!.Name); + } + + [Fact] + public void Apply_ShouldFilterByRoleName_IgnoringCase() + { + var tenantId = Guid.NewGuid(); + var adminRole = Role.CreateForTenant(tenantId, RoleNames.Admin, true); + var userRole = Role.CreateForTenant(tenantId, RoleNames.User, true); + var admin = CreateUser("Admin Person", "admin@example.test", adminRole.Id!); + var member = CreateUser("Member Person", "member@example.test", userRole.Id!); + + var result = new GetActiveTenantMembershipsForDirectoryQueryObject(tenantId, role: "aDmIn") + .Apply(new[] + { + CreateMembership(tenantId, admin, adminRole, active: true), + CreateMembership(tenantId, member, userRole, active: true) + }.AsQueryable().BuildMock()) + .ToList(); + + var membership = Assert.Single(result); + Assert.Equal("admin@example.test", membership.User!.Email); + } + + [Fact] + public void Apply_ShouldCombineSearchTermAndRoleFilters() + { + var tenantId = Guid.NewGuid(); + var adminRole = Role.CreateForTenant(tenantId, RoleNames.Admin, true); + var userRole = Role.CreateForTenant(tenantId, RoleNames.User, true); + var matchingAdmin = CreateUser("Brown, Catriona", "catriona@example.test", adminRole.Id!); + var sameNameDifferentRole = CreateUser("Brown, Barry", "barry@example.test", userRole.Id!); + var sameRoleDifferentName = CreateUser("Singh, Priya", "priya@example.test", adminRole.Id!); + + var result = new GetActiveTenantMembershipsForDirectoryQueryObject( + tenantId, + searchTerm: "brown", + role: RoleNames.Admin) + .Apply(new[] + { + CreateMembership(tenantId, matchingAdmin, adminRole, active: true), + CreateMembership(tenantId, sameNameDifferentRole, userRole, active: true), + CreateMembership(tenantId, sameRoleDifferentName, adminRole, active: true) + }.AsQueryable().BuildMock()) + .ToList(); + + var membership = Assert.Single(result); + Assert.Equal("catriona@example.test", membership.User!.Email); + } + private static User CreateUser(string name, string email, RoleId roleId) { return new User(