From f9f5188a5099f6d2181623149ef27868118c9fb2 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 4 Jun 2026 14:18:04 -0700 Subject: [PATCH 01/51] AB#33333 tenant creation improvements --- .../PaymentsQueryableExtensions.cs | 2 +- .../ITenantConnectionStringBuilder.cs | 7 +- .../TenantDbCredentials.cs | 16 +++ .../TenantAppService.cs | 7 +- .../TenantConnectionStringBuilder.cs | 95 +++++++++++-- .../UnityTenantManagementConsts.cs | 2 +- .../TenantAppService_Tests.cs | 5 +- ...ameworkCoreGrantManagerDbSchemaMigrator.cs | 131 ++++++++++++++++-- 8 files changed, 237 insertions(+), 28 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs index 5d5212de66..49292689f5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs @@ -11,7 +11,7 @@ public static IQueryable IncludeDetails(this IQueryable s.Site) - .ThenInclude(site => site.Supplier) + .ThenInclude(site => site!.Supplier) .Include(p => p.AccountCoding) .Include(y => y.ExpenseApprovals); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs index 10d36e8ec5..9a2d7ad422 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs @@ -1,9 +1,12 @@ -using Volo.Abp.Application.Services; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; namespace Unity.TenantManagement.Application.Contracts { public interface ITenantConnectionStringBuilder : IApplicationService { - string Build(string tenantName); + Task GenerateCredentialsAsync(); + + string Build(string tenantName, TenantDbCredentials credentials); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs new file mode 100644 index 0000000000..3c2fa35c8e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs @@ -0,0 +1,16 @@ +namespace Unity.TenantManagement.Application.Contracts +{ + public class TenantDbCredentials + { + public TenantDbCredentials(string dbName, string username, string password) + { + DbName = dbName; + Username = username; + Password = password; + } + + public string DbName { get; } + public string Username { get; } + public string Password { get; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 4d1e1b01b2..84080c7d6c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -137,15 +137,18 @@ public virtual async Task CreateAsync(TenantCreateDto input) Tenant tenant = null; using (var uow = unitOfWorkManager.Begin(true, false)) - { + { tenant = await tenantManager.CreateAsync(input.Name); + var credentials = await tenantConnectionStringBuilder.GenerateCredentialsAsync(); + tenant.ConnectionStrings .Add(new TenantConnectionString(tenant.Id, UnityTenantManagementConsts.TenantConnectionStringName, - tenantConnectionStringBuilder.Build(tenant.Name))); + tenantConnectionStringBuilder.Build(tenant.Name, credentials))); // Set ExtraProperties from input + tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs index 1e83ae5238..d0ea5acde4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs @@ -1,33 +1,108 @@ -using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; using Unity.TenantManagement.Application.Contracts; using Volo.Abp; using Volo.Abp.Application.Services; +using Volo.Abp.TenantManagement; namespace Unity.TenantManagement.Application { [RemoteService(false)] public class TenantConnectionStringBuilder : ApplicationService, ITenantConnectionStringBuilder { + private static readonly char[] Letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".ToCharArray(); + private static readonly char[] Digits = "0123456789".ToCharArray(); + private static readonly char[] Alphanumeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".ToCharArray(); + private readonly IConfiguration _configuration; + private readonly ITenantRepository _tenantRepository; - public TenantConnectionStringBuilder(IConfiguration configuration) + public TenantConnectionStringBuilder(IConfiguration configuration, ITenantRepository tenantRepository) { _configuration = configuration; + _tenantRepository = tenantRepository; } - public string Build(string tenantName) + public string Build(string tenantName, TenantDbCredentials credentials) { - var connectionString = _configuration.GetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName); + var baseConnectionString = _configuration.GetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName) + ?? throw new UserFriendlyException("Connection string configuration error"); + + return ReplaceKeyValues(baseConnectionString, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Database"] = credentials.DbName, + ["Username"] = credentials.Username, + ["Password"] = credentials.Password + }); + } - return connectionString == null - ? throw new UserFriendlyException("Connection string configuration error") - : connectionString - .Replace(UnityTenantManagementConsts.TenantConnectionStringTenantDb, PrepTenantName(tenantName)); + // Replaces connection string values by key (case-insensitive) while preserving + // the original key casing from the template (e.g. "Host" stays "Host", not "host"). + private static string ReplaceKeyValues(string connectionString, Dictionary replacements) + { + var parts = connectionString.Split(';'); + for (int i = 0; i < parts.Length; i++) + { + var eq = parts[i].IndexOf('='); + if (eq > 0 && replacements.TryGetValue(parts[i][..eq].Trim(), out var newValue)) + { + parts[i] = $"{parts[i][..eq]}={newValue}"; + } + } + return string.Join(";", parts); + } + + public async Task GenerateCredentialsAsync() + { + var allTenants = await _tenantRepository.GetListAsync(nameof(Tenant.Name), int.MaxValue, 0, null, includeDetails: true); + + var existingConnectionStrings = allTenants + .SelectMany(t => t.ConnectionStrings.Select(cs => cs.Value)) + .ToList(); + + string dbName; + do + { + dbName = GenerateDbName(); + } + while (existingConnectionStrings.Any(cs => cs.Contains(dbName, StringComparison.OrdinalIgnoreCase))); + + return new TenantDbCredentials(dbName, dbName, GeneratePassword()); + } + + private static string GenerateDbName() + { + // Format: T_XXX999 where X is A-Z and 9 is 0-9, e.g. T_ABC123 + Span chars = + [ + 'T', + '_', + Letters[Random.Shared.Next(Letters.Length)], + Letters[Random.Shared.Next(Letters.Length)], + Letters[Random.Shared.Next(Letters.Length)], + Digits[Random.Shared.Next(Digits.Length)], + Digits[Random.Shared.Next(Digits.Length)], + Digits[Random.Shared.Next(Digits.Length)] + ]; + return new string(chars); } - private static string PrepTenantName(string tenantName) + private static string GeneratePassword() { - return tenantName.Trim().Replace(" ", ""); + // 6 random alphanumeric characters (A-Z, 0-9) + Span chars = + [ + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)] + ]; + return new string(chars); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs index 0c6d6833fe..fc934df332 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs @@ -4,6 +4,6 @@ public static class UnityTenantManagementConsts { public const string TenantConnectionStringName = "Tenant"; - public const string TenantConnectionStringTenantDb = "UnityGrantTenant"; + public const string TenantLicencePlateExtraPropertyKey = "LicencePlate"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs index 37dcc34065..e1cafe5057 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs @@ -19,7 +19,10 @@ protected override void AfterAddApplication(IServiceCollection services) // Create a Substitute and replace original one in Service Collection var tenantConnectionStringBuilder = Substitute.For(); - tenantConnectionStringBuilder.Build(Arg.Any()).Returns("acme test connection"); + tenantConnectionStringBuilder.GenerateCredentialsAsync() + .Returns(Task.FromResult(new TenantDbCredentials("T_ABC123", "T_ABC123", "XYZ789"))); + tenantConnectionStringBuilder.Build(Arg.Any(), Arg.Any()) + .Returns("acme test connection"); services.AddSingleton(tenantConnectionStringBuilder); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs index 78ec9708fd..1c8e8a1c43 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs @@ -1,7 +1,12 @@ -using System; +using System; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Npgsql; using Unity.GrantManager.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.TenantManagement; @@ -20,10 +25,9 @@ public EntityFrameworkCoreGrantManagerDbSchemaMigrator(IServiceProvider serviceP public async Task MigrateAsync(Tenant? tenant) { - /* We intentionally resolving the GrantManagerDbContext - * from IServiceProvider (instead of directly injecting it) - * to properly get the connection string of the current tenant in the - * current scope. + /* We intentionally resolve GrantManagerDbContext / GrantTenantDbContext + * from IServiceProvider (instead of directly injecting) to properly get + * the connection string of the current tenant in the current scope. */ if (tenant != null) @@ -31,17 +35,57 @@ public async Task MigrateAsync(Tenant? tenant) var connectionString = tenant.ConnectionStrings[0]; if (connectionString != null) { + var configuration = _serviceProvider.GetRequiredService(); + var adminConnectionString = configuration.GetConnectionString(GrantManagerConsts.DefaultConnectionStringName) + ?? throw new InvalidOperationException($"Connection string '{GrantManagerConsts.DefaultConnectionStringName}' is not configured."); + + // Parse the stored tenant connection string to get the DB name and role credentials + var tenantCsb = new NpgsqlConnectionStringBuilder(connectionString.Value); + var dbName = tenantCsb.Database + ?? throw new InvalidOperationException("Tenant connection string is missing the Database value."); + var roleName = tenantCsb.Username + ?? throw new InvalidOperationException("Tenant connection string is missing the Username value."); + var rolePassword = tenantCsb.Password + ?? throw new InvalidOperationException("Tenant connection string is missing the Password value."); + + // Build an admin-level connection string targeting the tenant database + var adminCsb = new NpgsqlConnectionStringBuilder(adminConnectionString) { Database = dbName }; + var adminTenantConnectionString = adminCsb.ToString(); + + // Create the PostgreSQL role (idempotent via DO block) + await CreateRoleIfNotExistsAsync(adminConnectionString, roleName, rolePassword); + + // Create the database if it does not exist; use the admin connection so + // MigrateAsync connects cleanly and avoids logging a ConnectionError. var tenantDb = _serviceProvider - .GetRequiredService() - .Database; + .GetRequiredService() + .Database; + + tenantDb.SetConnectionString(adminTenantConnectionString); + + if (!await tenantDb.CanConnectAsync()) + { + await tenantDb.GetService().CreateAsync(); + } - tenantDb.SetConnectionString(connectionString.Value); + // Grant database and schema privileges to the role (idempotent) + await GrantDatabasePrivilegesAsync(adminConnectionString, dbName, roleName); + await GrantSchemaPrivilegesAsync(adminTenantConnectionString, roleName); + + // Ensure __EFMigrationsHistory exists so MigrateAsync does not log a CommandError + await tenantDb.ExecuteSqlRawAsync( + tenantDb.GetService().GetCreateIfNotExistsScript()); + + // Run migrations as admin against the tenant database await tenantDb.MigrateAsync(); + // Grant table and sequence privileges after migrations have created all objects + await GrantTablePrivilegesAsync(adminTenantConnectionString, roleName); + /* The payments module is also migrated. - Currently the payments module also reference the tenant connection string. - Changes to that, may require an inspection in the connections string here and resolve the correct one. - */ + Currently the payments module also references the tenant connection string. + Changes to that may require inspecting the connection string here to resolve + the correct one. */ } } else @@ -52,4 +96,69 @@ await _serviceProvider .MigrateAsync(); } } + + private static async Task CreateRoleIfNotExistsAsync(string adminConnectionString, string roleName, string password) + { + await using var conn = new NpgsqlConnection(adminConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{roleName}') THEN + CREATE ROLE "{roleName}" WITH LOGIN PASSWORD '{password}'; + END IF; + END + $$; + """; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task GrantDatabasePrivilegesAsync(string adminConnectionString, string dbName, string roleName) + { + await using var conn = new NpgsqlConnection(adminConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"GRANT ALL PRIVILEGES ON DATABASE \"{dbName}\" TO \"{roleName}\""; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task GrantSchemaPrivilegesAsync(string adminTenantConnectionString, string roleName) + { + await using var conn = new NpgsqlConnection(adminTenantConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"GRANT ALL PRIVILEGES ON SCHEMA public TO \"{roleName}\""; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task GrantTablePrivilegesAsync(string adminTenantConnectionString, string roleName) + { + await using var conn = new NpgsqlConnection(adminTenantConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + + // Iterate over every non-system schema so schemas like "Notifications" created + // by migrations are covered in addition to the default "public" schema. + cmd.CommandText = $""" + DO $$ + DECLARE + r RECORD; + BEGIN + FOR r IN + SELECT nspname FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND nspname NOT LIKE 'pg_%' + LOOP + EXECUTE format('GRANT ALL PRIVILEGES ON SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO "{roleName}"', r.nspname); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO "{roleName}"', r.nspname); + END LOOP; + END + $$; + """; + await cmd.ExecuteNonQueryAsync(); + } } From 90f6eba2a794a45818c54b8cb17e41a34447bd3a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 5 Jun 2026 13:08:56 -0700 Subject: [PATCH 02/51] AB#333333 encrypted tenant connection strings --- ...EncryptedTenantConnectionStringResolver.cs | 41 ++++++++++++ .../TenantAppService.cs | 9 ++- .../TenantConnectionStringBuilder.cs | 9 +-- .../Navigation/TenantManagementMenuNames.cs | 1 + .../Decrypt-TenantConnectionString.ps1 | 65 +++++++++++++++++++ .../Encrypt-TenantConnectionString.ps1 | 65 +++++++++++++++++++ .../Data/GrantManagerDbMigrationService.cs | 7 +- ...enantConnectionStringEncryptionMigrator.cs | 48 ++++++++++++++ ...ameworkCoreGrantManagerDbSchemaMigrator.cs | 25 ++++++- 9 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs create mode 100644 applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 create mode 100644 applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs new file mode 100644 index 0000000000..06ad027ff7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Encryption; + +namespace Unity.TenantManagement.Application; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IConnectionStringResolver))] +public class EncryptedTenantConnectionStringResolver : MultiTenantConnectionStringResolver +{ + private readonly IStringEncryptionService _encryptionService; + + public EncryptedTenantConnectionStringResolver( + IOptionsMonitor options, + ICurrentTenant currentTenant, + IServiceProvider serviceProvider, + IStringEncryptionService encryptionService) + : base(options, currentTenant, serviceProvider) + { + _encryptionService = encryptionService; + } + + public override async Task ResolveAsync(string connectionStringName = null) + { + var value = await base.ResolveAsync(connectionStringName); + if (string.IsNullOrEmpty(value)) return value; + try + { + var decrypted = _encryptionService.Decrypt(value); + return (decrypted != null && decrypted.Contains('=')) ? decrypted : value; + } + catch + { + return value; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 84080c7d6c..612b475f9c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -11,6 +11,7 @@ using Volo.Abp.Data; using Volo.Abp.EventBus.Local; using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; using Volo.Abp.Uow; using Volo.Abp.DependencyInjection; @@ -25,7 +26,8 @@ public class TenantAppService( ITenantManager tenantManager, ILocalEventBus localEventBus, IUnitOfWorkManager unitOfWorkManager, - ITenantConnectionStringBuilder tenantConnectionStringBuilder) : TenantManagementAppServiceBase, ITenantAppService + ITenantConnectionStringBuilder tenantConnectionStringBuilder, + IStringEncryptionService stringEncryptionService) : TenantManagementAppServiceBase, ITenantAppService { private const string ExtraPropDivision = "Division"; private const string ExtraPropBranch = "Branch"; @@ -142,10 +144,13 @@ public virtual async Task CreateAsync(TenantCreateDto input) var credentials = await tenantConnectionStringBuilder.GenerateCredentialsAsync(); + var plainConnectionString = tenantConnectionStringBuilder.Build(tenant.Name, credentials); + var encryptedConnectionString = stringEncryptionService.Encrypt(plainConnectionString); + tenant.ConnectionStrings .Add(new TenantConnectionString(tenant.Id, UnityTenantManagementConsts.TenantConnectionStringName, - tenantConnectionStringBuilder.Build(tenant.Name, credentials))); + encryptedConnectionString)); // Set ExtraProperties from input tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs index d0ea5acde4..256c2ba724 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs @@ -59,16 +59,17 @@ public async Task GenerateCredentialsAsync() { var allTenants = await _tenantRepository.GetListAsync(nameof(Tenant.Name), int.MaxValue, 0, null, includeDetails: true); - var existingConnectionStrings = allTenants - .SelectMany(t => t.ConnectionStrings.Select(cs => cs.Value)) - .ToList(); + var existingDbNames = allTenants + .Where(t => t.ExtraProperties.ContainsKey(UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey)) + .Select(t => t.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey]?.ToString() ?? "") + .ToHashSet(StringComparer.OrdinalIgnoreCase); string dbName; do { dbName = GenerateDbName(); } - while (existingConnectionStrings.Any(cs => cs.Contains(dbName, StringComparison.OrdinalIgnoreCase))); + while (existingDbNames.Contains(dbName)); return new TenantDbCredentials(dbName, dbName, GeneratePassword()); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs index 007c2d6ab7..9c44cbbddd 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs @@ -7,4 +7,5 @@ public static class TenantManagementMenuNames public const string Tenants = GroupName + ".Tenants"; public const string Endpoints = GroupName + ".Endpoints"; public const string Reconciliation = GroupName + ".Reconciliation"; + public const string Onboarding = GroupName + ".Onboarding"; } diff --git a/applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 b/applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 new file mode 100644 index 0000000000..32ab789e89 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS + Decrypts an ABP-encrypted tenant connection string. + +.DESCRIPTION + Mirrors ABP's StringEncryptionService (AES-256-CBC, PBKDF2/SHA-1, 1000 iterations). + Use this to recover a plain-text connection string from the value stored in the + AbpTenantConnectionStrings table. + +.PARAMETER EncryptedText + The base64-encoded encrypted value from the database. + +.PARAMETER PassPhrase + The pass phrase from appsettings.json -> StringEncryption -> DefaultPassPhrase. + +.PARAMETER Salt + The salt used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER InitVector + The AES initialisation vector used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER KeySize + AES key size in bits. Must match the value configured in AbpStringEncryptionOptions (default: 256). + +.EXAMPLE + .\Decrypt-TenantConnectionString.ps1 ` + -EncryptedText "abc123==" ` + -PassPhrase "g2IuZx7PwXDvCmlW" +#> +param( + [Parameter(Mandatory)][string] $EncryptedText, + [Parameter(Mandatory)][string] $PassPhrase, + [string] $Salt = "hgt!16kl", + [string] $InitVector = "jkE49230Tf093b42", + [int] $KeySize = 256 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$cipherBytes = [Convert]::FromBase64String($EncryptedText) +$saltBytes = [Text.Encoding]::ASCII.GetBytes($Salt) +$ivBytes = [Text.Encoding]::ASCII.GetBytes($InitVector) + +$deriveBytes = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($PassPhrase, $saltBytes, 1000) +$keyBytes = $deriveBytes.GetBytes($KeySize / 8) + +$aes = [System.Security.Cryptography.Aes]::Create() +$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC +$aes.Key = $keyBytes +$aes.IV = $ivBytes + +$ms = New-Object System.IO.MemoryStream(, $cipherBytes) +$cs = New-Object System.Security.Cryptography.CryptoStream($ms, $aes.CreateDecryptor(), [System.Security.Cryptography.CryptoStreamMode]::Read) +$sr = New-Object System.IO.StreamReader($cs) + +try { + Write-Output $sr.ReadToEnd() +} finally { + $sr.Dispose() + $cs.Dispose() + $ms.Dispose() + $aes.Dispose() + $deriveBytes.Dispose() +} diff --git a/applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 b/applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 new file mode 100644 index 0000000000..450a53fca3 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS + Encrypts a plain-text tenant connection string using ABP's StringEncryptionService algorithm. + +.DESCRIPTION + Mirrors ABP's StringEncryptionService (AES-256-CBC, PBKDF2/SHA-1, 1000 iterations). + Use this to produce the encrypted value that should be stored in the + AbpTenantConnectionStrings table, or to verify an existing encrypted value. + +.PARAMETER PlainText + The plain-text connection string to encrypt. + +.PARAMETER PassPhrase + The pass phrase from appsettings.json -> StringEncryption -> DefaultPassPhrase. + +.PARAMETER Salt + The salt used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER InitVector + The AES initialisation vector used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER KeySize + AES key size in bits. Must match the value configured in AbpStringEncryptionOptions (default: 256). + +.EXAMPLE + .\Encrypt-TenantConnectionString.ps1 ` + -PlainText "Host=localhost;Database=T_ABC123;Username=T_ABC123;Password=XYZ789" ` + -PassPhrase "g2IuZx7PwXDvCmlW" +#> +param( + [Parameter(Mandatory)][string] $PlainText, + [Parameter(Mandatory)][string] $PassPhrase, + [string] $Salt = "hgt!16kl", + [string] $InitVector = "jkE49230Tf093b42", + [int] $KeySize = 256 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$plainBytes = [Text.Encoding]::UTF8.GetBytes($PlainText) +$saltBytes = [Text.Encoding]::ASCII.GetBytes($Salt) +$ivBytes = [Text.Encoding]::ASCII.GetBytes($InitVector) + +$deriveBytes = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($PassPhrase, $saltBytes, 1000) +$keyBytes = $deriveBytes.GetBytes($KeySize / 8) + +$aes = [System.Security.Cryptography.Aes]::Create() +$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC +$aes.Key = $keyBytes +$aes.IV = $ivBytes + +$ms = New-Object System.IO.MemoryStream +$cs = New-Object System.Security.Cryptography.CryptoStream($ms, $aes.CreateEncryptor(), [System.Security.Cryptography.CryptoStreamMode]::Write) + +try { + $cs.Write($plainBytes, 0, $plainBytes.Length) + $cs.FlushFinalBlock() + Write-Output ([Convert]::ToBase64String($ms.ToArray())) +} finally { + $cs.Dispose() + $ms.Dispose() + $aes.Dispose() + $deriveBytes.Dispose() +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs index 5d46e99d1e..f300cb738c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs @@ -24,17 +24,20 @@ public class GrantManagerDbMigrationService : ITransientDependency private readonly IEnumerable _dbSchemaMigrators; private readonly ITenantRepository _tenantRepository; private readonly ICurrentTenant _currentTenant; + private readonly TenantConnectionStringEncryptionMigrator _connectionStringEncryptionMigrator; public GrantManagerDbMigrationService( IDataSeeder dataSeeder, IEnumerable dbSchemaMigrators, ITenantRepository tenantRepository, - ICurrentTenant currentTenant) + ICurrentTenant currentTenant, + TenantConnectionStringEncryptionMigrator connectionStringEncryptionMigrator) { _dataSeeder = dataSeeder; _dbSchemaMigrators = dbSchemaMigrators; _tenantRepository = tenantRepository; _currentTenant = currentTenant; + _connectionStringEncryptionMigrator = connectionStringEncryptionMigrator; Logger = NullLogger.Instance; } @@ -53,6 +56,8 @@ public async Task MigrateAsync() await MigrateDatabaseSchemaAsync(); await SeedDataAsync(); + await _connectionStringEncryptionMigrator.MigrateAsync(); + var tenants = await _tenantRepository.GetListAsync(includeDetails: true); var migratedDatabaseSchemas = new HashSet(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs new file mode 100644 index 0000000000..46597cdde6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; +using Volo.Abp.TenantManagement; + +namespace Unity.GrantManager.Data; + +public class TenantConnectionStringEncryptionMigrator( + ITenantRepository tenantRepository, + IStringEncryptionService encryptionService) : ITransientDependency +{ + public async Task MigrateAsync() + { + var tenants = await tenantRepository.GetListAsync(includeDetails: true); + + foreach (var tenant in tenants) + { + // IsPlainText returns true when the value is not valid ciphertext (not yet encrypted) + var plainTextStrings = tenant.ConnectionStrings + .Where(cs => IsPlainText(cs.Value)) + .ToList(); + + foreach (var cs in plainTextStrings) + { + tenant.SetConnectionString(cs.Name, encryptionService.Encrypt(cs.Value)); + } + + if (plainTextStrings.Count > 0) + { + await tenantRepository.UpdateAsync(tenant); + } + } + } + + private bool IsPlainText(string value) + { + try + { + encryptionService.Decrypt(value); + return false; + } + catch + { + return true; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs index 1c8e8a1c43..b7bba5eeb2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs @@ -9,6 +9,7 @@ using Npgsql; using Unity.GrantManager.Data; using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; namespace Unity.GrantManager.EntityFrameworkCore; @@ -17,10 +18,14 @@ public class EntityFrameworkCoreGrantManagerDbSchemaMigrator : IGrantManagerDbSchemaMigrator, ITransientDependency { private readonly IServiceProvider _serviceProvider; + private readonly IStringEncryptionService _encryptionService; - public EntityFrameworkCoreGrantManagerDbSchemaMigrator(IServiceProvider serviceProvider) + public EntityFrameworkCoreGrantManagerDbSchemaMigrator( + IServiceProvider serviceProvider, + IStringEncryptionService encryptionService) { _serviceProvider = serviceProvider; + _encryptionService = encryptionService; } public async Task MigrateAsync(Tenant? tenant) @@ -39,8 +44,24 @@ public async Task MigrateAsync(Tenant? tenant) var adminConnectionString = configuration.GetConnectionString(GrantManagerConsts.DefaultConnectionStringName) ?? throw new InvalidOperationException($"Connection string '{GrantManagerConsts.DefaultConnectionStringName}' is not configured."); + // Decrypt the stored value — plain-text rows (pre-encryption) fall back to their original value. + // A successful decrypt that produces non-connection-string output (wrong passphrase returning garbage) + // is also rejected by the Contains('=') check so we surface a meaningful error rather than a + // cryptic NpgsqlConnectionStringBuilder format exception. + var rawValue = connectionString.Value; + string plainValue; + try + { + var decrypted = _encryptionService.Decrypt(rawValue); + plainValue = (decrypted != null && decrypted.Contains('=')) ? decrypted : rawValue; + } + catch + { + plainValue = rawValue; + } + // Parse the stored tenant connection string to get the DB name and role credentials - var tenantCsb = new NpgsqlConnectionStringBuilder(connectionString.Value); + var tenantCsb = new NpgsqlConnectionStringBuilder(plainValue); var dbName = tenantCsb.Database ?? throw new InvalidOperationException("Tenant connection string is missing the Database value."); var roleName = tenantCsb.Username From b2b688dc167f7ed9aae920d343b5111897648fac Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 11 Jun 2026 17:17:26 -0700 Subject: [PATCH 03/51] AB#33309 onboarding WIP --- .../Unity.AI.Web/Menus/AIMenuContributor.cs | 21 +- .../Features/FeatureConsts.cs | 1 + .../Navigation/MenuItemExtensions.cs | 104 ++++ .../Specializations/ISpecializationChecker.cs | 8 + .../Specializations/SpecializationChecker.cs | 10 + .../Specializations/SpecializationConsts.cs | 8 + .../IOnboardingRequestAppService.cs | 15 + .../IOnboardingUserLookup.cs | 11 + .../IOnboardingValidationStep.cs | 10 + .../ITenantAppService.cs | 4 + .../ITenantConnectionStringBuilder.cs | 2 + .../OnboardingRequestDto.cs | 21 + .../OnboardingValidationResultDto.cs | 9 + .../OnboardingValidationStepResult.cs | 11 + .../TenantConnectionStringsDto.cs | 8 + .../TenantCreateDto.cs | 2 + .../TenantDto.cs | 1 + .../TenantManagementPermissions.cs | 7 + .../TenantManagerDto.cs | 7 + .../Onboarding/OnboardingFeatureMap.cs | 38 ++ .../OnboardingRequestAppService.cs | 236 ++++++++ .../TenantAppService.cs | 94 +++- .../TenantConnectionStringBuilder.cs | 9 +- .../Unity.TenantManagement.Application.csproj | 2 + .../UnityTenantManagementConsts.cs | 2 + .../UnityTenantManagementMapperlyProfile.cs | 1 + .../Validation/SuperUsersValidationStep.cs | 36 ++ .../Validation/TenantNameUniquenessStep.cs | 27 + .../ClientProxies/TenantClientProxy.cs | 27 + .../OnboardingRequestController.cs | 47 ++ .../TenantController.cs | 34 ++ ...yTenantManagementWebMainMenuContributor.cs | 6 +- .../Onboarding/CreateTenantModal.cshtml | 54 ++ .../Onboarding/CreateTenantModal.cshtml.cs | 25 + .../TenantManagement/Onboarding/Index.cshtml | 41 ++ .../Onboarding/Index.cshtml.cs | 10 + .../TenantManagement/Onboarding/Index.js | 156 ++++++ .../Onboarding/OnboardingPageModel.cs | 12 + .../Onboarding/_ViewImports.cshtml | 4 + .../Tenants/ConfigurationModal.cshtml | 194 +++++++ .../Tenants/ConfigurationModal.cshtml.cs | 155 ++++++ .../TenantManagement/Tenants/Index.cshtml | 22 +- .../Pages/TenantManagement/Tenants/Index.js | 511 ++++++++++++------ ...UnityTenantManagementWebMapperlyProfile.cs | 61 +++ .../UnityTenantManagementWebModule.cs | 8 +- .../client-proxies/multi-tenancy-proxy.js | 28 + .../TenantAppService_Tests.cs | 9 + .../GrantManagerFeaturesDefinitionProvider.cs | 9 + .../Identity/UserSearchDto.cs | 2 + .../Integration/Css/ICssUsersApiService.cs | 2 +- .../ApplicationFormVersionAppService.cs | 6 +- .../Handlers/TenantCreatedEventHandler.cs | 30 +- .../Identity/CssOnboardingUserLookup.cs | 24 + .../Identity/UserImportAppService.cs | 15 +- .../Integrations/Css/CssApiService.cs | 5 +- .../appsettings.json | 6 +- .../Localization/GrantManager/en.json | 1 + .../IApplicationFormSubmissionRepository.cs | 2 +- .../GrantManagerConsts.cs | 6 + ...ntManagerDefaultTenantSeederContributor.cs | 78 ++- ...ameworkCoreGrantManagerDbSchemaMigrator.cs | 91 +++- .../ApplicationFormSubmissionRepository.cs | 4 +- .../Identity/PolicyRegistrant.cs | 30 +- .../Menus/GrantManagerMenuContributor.cs | 96 ++-- .../Pages/GrantPrograms/Index.cshtml.cs | 32 +- 65 files changed, 2256 insertions(+), 292 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/ISpecializationChecker.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationChecker.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationConsts.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingUserLookup.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingValidationStep.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationResultDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationStepResult.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantConnectionStringsDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagerDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/TenantNameUniquenessStep.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/OnboardingPageModel.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/_ViewImports.cshtml create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/CssOnboardingUserLookup.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs index bf7f06d896..50eea177db 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Unity.AI.Localization; using Unity.AI.Permissions; +using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; using Volo.Abp.Features; using Volo.Abp.UI.Navigation; @@ -23,14 +24,18 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex var l = context.GetLocalizer(); var featureChecker = context.ServiceProvider.GetRequiredService(); - context.Menu.AddItem(new ApplicationMenuItem( - name: AIMenus.Prompts, - displayName: "AI Prompts", - url: "~/Prompts", - icon: "fl fl-ai-prompts", - order: 900, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName - )); + var specializationChecker = context.ServiceProvider.GetRequiredService(); + if (!await specializationChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) + { + context.Menu.AddItem(new ApplicationMenuItem( + name: AIMenus.Prompts, + displayName: "AI Prompts", + url: "~/Prompts", + icon: "fl fl-ai-prompts", + order: 900, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + )); + } if (await featureChecker.IsEnabledAsync("Unity.AIReporting")) { diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs index f4e48b303d..cafbfba345 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs @@ -3,5 +3,6 @@ public static class FeatureConsts { public const string Reporting = "Unity.Reporting"; + public const string Onboarding = "Unity.Onboarding"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs new file mode 100644 index 0000000000..4c2a541385 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs @@ -0,0 +1,104 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Unity.Modules.Shared.Specializations; +using Volo.Abp.Features; +using Volo.Abp.UI.Navigation; + +namespace Unity.Modules.Shared.Navigation; + +public static class MenuItemExtensions +{ + private const string ExcludeWhenFeaturesKey = "_ExcludeWhenFeatures"; + private const string OnlyWhenFeaturesKey = "_OnlyWhenFeatures"; + private const string ExcludeWhenSpecializationsKey = "_ExcludeWhenSpecializations"; + private const string OnlyWhenSpecializationsKey = "_OnlyWhenSpecializations"; + + /// + /// Hides this menu item when any of the given features are enabled. + /// + public static ApplicationMenuItem ExcludeWhenFeatures( + this ApplicationMenuItem item, + params string[] featureNames) + { + item.CustomData[ExcludeWhenFeaturesKey] = featureNames; + return item; + } + + /// + /// Shows this menu item only when all of the given features are enabled. + /// + public static ApplicationMenuItem OnlyWhenFeatures( + this ApplicationMenuItem item, + params string[] featureNames) + { + item.CustomData[OnlyWhenFeaturesKey] = featureNames; + return item; + } + + /// + /// Hides this menu item when any of the given specializations are enabled. + /// + public static ApplicationMenuItem ExcludeWhenSpecializations( + this ApplicationMenuItem item, + params string[] specializationNames) + { + item.CustomData[ExcludeWhenSpecializationsKey] = specializationNames; + return item; + } + + /// + /// Shows this menu item only when all of the given specializations are enabled. + /// + public static ApplicationMenuItem OnlyWhenSpecializations( + this ApplicationMenuItem item, + params string[] specializationNames) + { + item.CustomData[OnlyWhenSpecializationsKey] = specializationNames; + return item; + } + + /// + /// Adds the item to the menu, respecting any feature or specialization visibility declarations. + /// + public static async Task AddItemAsync( + this MenuConfigurationContext context, + ApplicationMenuItem item) + { + var featureChecker = context.ServiceProvider.GetRequiredService(); + var specializationChecker = context.ServiceProvider.GetRequiredService(); + + if (item.CustomData.TryGetValue(ExcludeWhenFeaturesKey, out var excludeFeatObj) + && excludeFeatObj is string[] excludeFeatures) + { + foreach (var feature in excludeFeatures) + if (await featureChecker.IsEnabledAsync(feature)) + return; + } + + if (item.CustomData.TryGetValue(OnlyWhenFeaturesKey, out var onlyFeatObj) + && onlyFeatObj is string[] onlyFeatures) + { + foreach (var feature in onlyFeatures) + if (!await featureChecker.IsEnabledAsync(feature)) + return; + } + + if (item.CustomData.TryGetValue(ExcludeWhenSpecializationsKey, out var excludeSpecObj) + && excludeSpecObj is string[] excludeSpecs) + { + foreach (var spec in excludeSpecs) + if (await specializationChecker.IsEnabledAsync(spec)) + return; + } + + if (item.CustomData.TryGetValue(OnlyWhenSpecializationsKey, out var onlySpecObj) + && onlySpecObj is string[] onlySpecs) + { + foreach (var spec in onlySpecs) + if (!await specializationChecker.IsEnabledAsync(spec)) + return; + } + + context.Menu.AddItem(item); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/ISpecializationChecker.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/ISpecializationChecker.cs new file mode 100644 index 0000000000..57e0acb434 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/ISpecializationChecker.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.Specializations; + +public interface ISpecializationChecker +{ + Task IsEnabledAsync(string name); +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationChecker.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationChecker.cs new file mode 100644 index 0000000000..0f29fa7b1c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationChecker.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; + +namespace Unity.Modules.Shared.Specializations; + +public class SpecializationChecker(IFeatureChecker featureChecker) : ISpecializationChecker, ITransientDependency +{ + public Task IsEnabledAsync(string name) => featureChecker.IsEnabledAsync(name); +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationConsts.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationConsts.cs new file mode 100644 index 0000000000..eb95bab581 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationConsts.cs @@ -0,0 +1,8 @@ +namespace Unity.Modules.Shared.Specializations; + +public static class SpecializationConsts +{ + public const string GroupName = "Specializations"; + + public const string Onboarding = "Unity.Onboarding"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs new file mode 100644 index 0000000000..5e5542070e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs @@ -0,0 +1,15 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.TenantManagement; + +public interface IOnboardingRequestAppService : IApplicationService +{ + Task> GetListAsync(PagedAndSortedResultRequestDto input); + Task GetAsync(Guid id); + Task ValidateAsync(Guid id); + Task CreateTenantAsync(Guid id); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingUserLookup.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingUserLookup.cs new file mode 100644 index 0000000000..155c75194d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingUserLookup.cs @@ -0,0 +1,11 @@ +#nullable enable +using System.Threading.Tasks; + +namespace Unity.TenantManagement; + +/// Thin seam over the CSS directory API for onboarding use cases. +public interface IOnboardingUserLookup +{ + /// Returns the IDIR user GUID if the email resolves in the directory, null otherwise. + Task FindUserGuidByEmailAsync(string email); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingValidationStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingValidationStep.cs new file mode 100644 index 0000000000..d5097e4798 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingValidationStep.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace Unity.TenantManagement; + +public interface IOnboardingValidationStep +{ + int Order { get; } + string StepName { get; } + Task ValidateAsync(OnboardingRequestDto request); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs index ae68094937..31ec072566 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.Application.Services; @@ -10,4 +11,7 @@ public interface ITenantAppService : ICrudAppService GetConnectionStringsAsync(Guid id); + Task UpdateConnectionStringsAsync(Guid id, TenantConnectionStringsDto input); + Task> GetManagersAsync(Guid id); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs index 9a2d7ad422..c5b82716f8 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs @@ -7,6 +7,8 @@ public interface ITenantConnectionStringBuilder : IApplicationService { Task GenerateCredentialsAsync(); + TenantDbCredentials GenerateReadOnlyCredentials(TenantDbCredentials credentials); + string Build(string tenantName, TenantDbCredentials credentials); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs new file mode 100644 index 0000000000..1b3cb4c63a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs @@ -0,0 +1,21 @@ +using System; + +namespace Unity.TenantManagement; + +public class OnboardingRequestDto +{ + public Guid Id { get; set; } + public string TenantName { get; set; } = string.Empty; + public string TenantDescription { get; set; } = string.Empty; + public string ProgramAreaName { get; set; } = string.Empty; + public string ProgramAreaDescription { get; set; } = string.Empty; + public string Contacts { get; set; } = string.Empty; + public string Features { get; set; } = string.Empty; + public string SuperUsers { get; set; } = string.Empty; + public string ExecutiveDirector { get; set; } = string.Empty; + public string Branch { get; set; } = string.Empty; + public string Ministry { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public DateTime? SubmissionDate { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationResultDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationResultDto.cs new file mode 100644 index 0000000000..aeed66c0c6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationResultDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Unity.TenantManagement; + +public class OnboardingValidationResultDto +{ + public bool IsValid { get; set; } + public List Issues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationStepResult.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationStepResult.cs new file mode 100644 index 0000000000..eb28626bbb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationStepResult.cs @@ -0,0 +1,11 @@ +#nullable enable +namespace Unity.TenantManagement; + +public class OnboardingValidationStepResult +{ + public bool IsValid { get; set; } + public string? Issue { get; set; } + + public static OnboardingValidationStepResult Success() => new() { IsValid = true }; + public static OnboardingValidationStepResult Failure(string issue) => new() { IsValid = false, Issue = issue }; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantConnectionStringsDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantConnectionStringsDto.cs new file mode 100644 index 0000000000..60464c9de7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantConnectionStringsDto.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace Unity.TenantManagement; + +public class TenantConnectionStringsDto +{ + public string? TenantConnectionString { get; set; } + public string? ReadOnlyConnectionString { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs index e76eedd858..1fa985a095 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs @@ -3,4 +3,6 @@ public class TenantCreateDto : TenantCreateOrUpdateDtoBase { public string UserIdentifier { get; set; } + /// Comma-separated ABP feature keys to enable on the new tenant (e.g. "Unity.Payments,Unity.Reporting"). + public string? FeatureKeys { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs index b1aaf19b9e..5dc338b00c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs @@ -11,5 +11,6 @@ public class TenantDto : ExtensibleEntityDto, IHasConcurrencyStamp public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public string CasClientCode { get; set; } = string.Empty; + public string LicencePlate { get; set; } = string.Empty; public string ConcurrencyStamp { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs index 4b10adc481..12b1decc2f 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs @@ -18,6 +18,13 @@ public static class Tenants public const string ManageEndpoints = AbpGroupName + ".Tenants" + ".ManageEndpoints"; } + public static class Policies + { + public const string TenantsOrITOps = "TenantManagement.TenantsOrITOps"; + public const string TenantsUpdateOrITOps = "TenantManagement.TenantsUpdateOrITOps"; + public const string TenantsCreateOrITOps = "TenantManagement.TenantsCreateOrITOps"; + } + public static string[] GetAll() { return ReflectionHelper.GetPublicConstantsRecursively(typeof(TenantManagementPermissions)); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagerDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagerDto.cs new file mode 100644 index 0000000000..3dd083f1e1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagerDto.cs @@ -0,0 +1,7 @@ +namespace Unity.TenantManagement; + +public class TenantManagerDto +{ + public string DisplayName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs new file mode 100644 index 0000000000..1063e60fc4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Unity.TenantManagement.Onboarding; + +internal static class OnboardingFeatureMap +{ + private static readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase) + { + ["Payments"] = "Unity.Payments", + ["Notifications"] = "Unity.Notifications", + ["Flex"] = "Unity.Flex", + ["Reporting"] = "Unity.Reporting", + ["AI Reporting"] = "Unity.AIReporting", + ["AI Attachment Summaries"] = "Unity.AI.AttachmentSummaries", + ["AI Application Analysis"] = "Unity.AI.ApplicationAnalysis", + ["AI Scoring"] = "Unity.AI.Scoring", + ["Analytics"] = "Unity.Analytics", + }; + + /// + /// Parses a comma-separated features string and returns the feature keys for any recognised names. + /// Unrecognised names are silently skipped. + /// + public static IReadOnlyList ResolveFeatureKeys(string features) + { + if (string.IsNullOrWhiteSpace(features)) + return []; + + return [.. features + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(token => _map.TryGetValue(token, out var key) ? key : null) + .Where(key => key is not null) + .Cast() + .Distinct()]; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs new file mode 100644 index 0000000000..d3ea3ba23a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -0,0 +1,236 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; +using Unity.TenantManagement.Onboarding; +using Unity.TenantManagement.Validation; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.TenantManagement; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class OnboardingRequestAppService( + IEnumerable validationSteps +) : ApplicationService, IOnboardingRequestAppService +{ + private readonly IEnumerable _validationSteps = validationSteps; + + private ITenantAppService TenantAppService => + LazyServiceProvider.LazyGetRequiredService(); + + private IOnboardingUserLookup? UserLookup => + LazyServiceProvider.LazyGetService(); + + private static readonly List MockOnboardingRequests = + [ + new() + { + Id = Guid.NewGuid(), + TenantName = "Community Housing Programs", + TenantDescription = "Grant programs supporting community housing initiatives", + ProgramAreaName = "Housing Supply", + ProgramAreaDescription = "Programs that increase the supply of affordable housing", + Contacts = "Jane Smith, Tom Lee", + Features = "Application Intake, Reporting", + SuperUsers = "jane.smith@gov.bc.ca", + ExecutiveDirector = "Pat Wilson", + Branch = "Housing Policy Branch", + Ministry = "Ministry of Housing", + Status = "In Review", + Category = "Grant", + SubmissionDate = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc) + }, + new() + { + Id = Guid.NewGuid(), + TenantName = "Clean Energy Rebates", + TenantDescription = "Rebate programs for clean energy adoption", + ProgramAreaName = "Energy Efficiency", + ProgramAreaDescription = "Programs that promote energy efficient retrofits", + Contacts = "Alex Chen", + Features = "Application Intake, Payments, Reporting", + SuperUsers = "andre.goncalves@gov.bc.ca", + ExecutiveDirector = "Morgan Lee", + Branch = "Energy Programs Branch", + Ministry = "Ministry of Energy and Climate Solutions", + Status = "Approved", + Category = "Rebate", + SubmissionDate = new DateTime(2025, 11, 3, 0, 0, 0, DateTimeKind.Utc) + }, + new() + { + Id = Guid.NewGuid(), + TenantName = "Agricultural Innovation Fund", + TenantDescription = "Funding to support innovation in the agriculture sector", + ProgramAreaName = "Agri-Innovation", + ProgramAreaDescription = "Programs that fund innovative agricultural projects", + Contacts = "Priya Patel, Sam O'Brien", + Features = "Application Intake, Scoring, Reporting", + SuperUsers = "priya.patel@gov.bc.ca", + ExecutiveDirector = "Casey Brown", + Branch = "Industry Competitiveness Branch", + Ministry = "Ministry of Agriculture and Food", + Status = "Pending", + Category = "Grant", + SubmissionDate = new DateTime(2026, 3, 22, 0, 0, 0, DateTimeKind.Utc) + }, + new() + { + Id = Guid.NewGuid(), + TenantName = "Small Business Recovery Grants", + TenantDescription = "Grants supporting small business recovery and growth", + ProgramAreaName = "Business Supports", + ProgramAreaDescription = "Programs that provide direct supports to small businesses", + Contacts = "Jordan Taylor", + Features = "Application Intake, Payments", + SuperUsers = "jordan.taylor@gov.bc.ca", + ExecutiveDirector = "Riley Anderson", + Branch = "Small Business Branch", + Ministry = "Ministry of Jobs, Economic Development and Innovation", + Status = "In Review", + Category = "Grant", + SubmissionDate = new DateTime(2026, 2, 8, 0, 0, 0, DateTimeKind.Utc) + }, + new() + { + Id = Guid.NewGuid(), + TenantName = "Watershed Restoration Grants", + TenantDescription = "Grants funding the restoration of watershed ecosystems", + ProgramAreaName = "Environmental Stewardship", + ProgramAreaDescription = "Programs that support environmental restoration projects", + Contacts = "Taylor Morgan, Casey Nguyen", + Features = "Application Intake, Reporting", + SuperUsers = "taylor.morgan@gov.bc.ca", + ExecutiveDirector = "Drew Campbell", + Branch = "Ecosystems Branch", + Ministry = "Ministry of Water, Land and Resource Stewardship", + Status = "Approved", + Category = "Contribution Agreement", + SubmissionDate = new DateTime(2025, 9, 17, 0, 0, 0, DateTimeKind.Utc) + } + ]; + + public virtual Task GetAsync(Guid id) + { + return Task.FromResult(MockOnboardingRequests.FirstOrDefault(r => r.Id == id)); + } + + public virtual async Task ValidateAsync(Guid id) + { + var request = MockOnboardingRequests.FirstOrDefault(r => r.Id == id); + if (request == null) + return new OnboardingValidationResultDto + { + IsValid = false, + Issues = ["Onboarding request not found."] + }; + + var issues = new List(); + + foreach (var step in _validationSteps.OrderBy(s => s.Order)) + { + var stepResult = await step.ValidateAsync(request); + if (!stepResult.IsValid && stepResult.Issue is not null) + issues.Add($"[{step.StepName}] {stepResult.Issue}"); + } + + return new OnboardingValidationResultDto + { + IsValid = issues.Count == 0, + Issues = issues + }; + } + + public virtual async Task CreateTenantAsync(Guid id) + { + var request = MockOnboardingRequests.FirstOrDefault(r => r.Id == id) + ?? throw new UserFriendlyException("Onboarding request not found."); + + var emails = SuperUsersValidationStep.ParseEmails(request.SuperUsers); + + // Resolve all valid GUIDs in host context before tenant creation + var userGuids = new List(); + if (UserLookup is not null) + { + foreach (var email in emails) + { + var guid = await UserLookup.FindUserGuidByEmailAsync(email); + if (!string.IsNullOrWhiteSpace(guid)) + userGuids.Add(guid); + } + } + + if (userGuids.Count == 0) + throw new UserFriendlyException("No valid super users could be resolved. Cannot create tenant without at least one valid program manager."); + + var featureKeys = OnboardingFeatureMap.ResolveFeatureKeys(request.Features); + + // TenantCreatedEto handler migrates/seeds the DB, imports the first user, and enables features + var tenantDto = await TenantAppService.CreateAsync(new TenantCreateDto + { + Name = request.TenantName, + Branch = request.Branch, + Description = request.TenantDescription, + UserIdentifier = userGuids[0], + FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null + }); + + // Assign any additional super users as program managers + foreach (var userGuid in userGuids.Skip(1)) + { + await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto + { + TenantId = tenantDto.Id, + UserIdentifier = userGuid + }); + } + } + + public virtual Task> GetListAsync(PagedAndSortedResultRequestDto input) + { + var sorted = ApplySorting(MockOnboardingRequests, input.Sorting ?? string.Empty); + + var items = sorted + .Skip(input.SkipCount) + .Take(input.MaxResultCount) + .ToList(); + + return Task.FromResult(new PagedResultDto(MockOnboardingRequests.Count, items)); + } + + private static IEnumerable ApplySorting(IEnumerable query, string sorting) + { + if (sorting.IsNullOrWhiteSpace()) + { + return query.OrderBy(o => o.TenantName); + } + + var sortParts = sorting.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sortField = sortParts[0]; + var descending = sortParts.Length > 1 && sortParts[1].Equals("DESC", StringComparison.OrdinalIgnoreCase); + + Func keySelector = sortField switch + { + nameof(OnboardingRequestDto.TenantDescription) => o => o.TenantDescription, + nameof(OnboardingRequestDto.ProgramAreaName) => o => o.ProgramAreaName, + nameof(OnboardingRequestDto.ProgramAreaDescription) => o => o.ProgramAreaDescription, + nameof(OnboardingRequestDto.Contacts) => o => o.Contacts, + nameof(OnboardingRequestDto.Features) => o => o.Features, + nameof(OnboardingRequestDto.SuperUsers) => o => o.SuperUsers, + nameof(OnboardingRequestDto.ExecutiveDirector) => o => o.ExecutiveDirector, + nameof(OnboardingRequestDto.Branch) => o => o.Branch, + nameof(OnboardingRequestDto.Ministry) => o => o.Ministry, + nameof(OnboardingRequestDto.Status) => o => o.Status, + nameof(OnboardingRequestDto.Category) => o => o.Category, + nameof(OnboardingRequestDto.SubmissionDate) => o => o.SubmissionDate?.ToString("O") ?? "", + _ => o => o.TenantName + }; + + return descending ? query.OrderByDescending(keySelector) : query.OrderBy(keySelector); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 612b475f9c..749f8efcc0 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -1,4 +1,5 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -15,10 +16,11 @@ using Volo.Abp.TenantManagement; using Volo.Abp.Uow; using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; namespace Unity.TenantManagement; -[Authorize(TenantManagementPermissions.Tenants.Default)] +[Authorize(TenantManagementPermissions.Policies.TenantsOrITOps)] [ExposeServices(typeof(ITenantAppService), typeof(TenantAppService))] public class TenantAppService( ICurrentTenant currentTenant, @@ -29,6 +31,8 @@ public class TenantAppService( ITenantConnectionStringBuilder tenantConnectionStringBuilder, IStringEncryptionService stringEncryptionService) : TenantManagementAppServiceBase, ITenantAppService { + private IIdentityUserRepository IdentityUserRepository => LazyServiceProvider.LazyGetRequiredService(); + private const string ExtraPropDivision = "Division"; private const string ExtraPropBranch = "Branch"; private const string ExtraPropDescription = "Description"; @@ -133,10 +137,10 @@ private static string GetExtraPropertyValue(Tenant tenant, string key) return entry.Value?.ToString() ?? string.Empty; } - [Authorize(TenantManagementPermissions.Tenants.Create)] + [Authorize(TenantManagementPermissions.Policies.TenantsCreateOrITOps)] public virtual async Task CreateAsync(TenantCreateDto input) { - Tenant tenant = null; + Tenant? tenant = null; using (var uow = unitOfWorkManager.Begin(true, false)) { @@ -152,6 +156,15 @@ public virtual async Task CreateAsync(TenantCreateDto input) UnityTenantManagementConsts.TenantConnectionStringName, encryptedConnectionString)); + var readOnlyCredentials = tenantConnectionStringBuilder.GenerateReadOnlyCredentials(credentials); + var plainReadOnlyConnectionString = tenantConnectionStringBuilder.Build(tenant.Name, readOnlyCredentials); + var encryptedReadOnlyConnectionString = stringEncryptionService.Encrypt(plainReadOnlyConnectionString); + + tenant.ConnectionStrings + .Add(new TenantConnectionString(tenant.Id, + UnityTenantManagementConsts.TenantReadOnlyConnectionStringName, + encryptedReadOnlyConnectionString)); + // Set ExtraProperties from input tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; @@ -172,7 +185,8 @@ await localEventBus.PublishAsync( Name = tenant.Name, Properties = { - { "UserIdentifier", input.UserIdentifier } + { "UserIdentifier", input.UserIdentifier }, + { "FeatureKeys", input.FeatureKeys ?? string.Empty } } } ); @@ -180,7 +194,7 @@ await localEventBus.PublishAsync( return ObjectMapper.Map(tenant); } - [Authorize(TenantManagementPermissions.Tenants.Update)] + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] public virtual async Task UpdateAsync(Guid id, TenantUpdateDto input) { var tenant = await tenantRepository.GetAsync(id); @@ -216,7 +230,7 @@ public virtual async Task DeleteAsync(Guid id) public virtual async Task GetDefaultConnectionStringAsync(Guid id) { var tenant = await tenantRepository.GetAsync(id); - return tenant?.FindDefaultConnectionString(); + return tenant?.FindDefaultConnectionString()!; } [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] @@ -240,7 +254,7 @@ public virtual async Task DeleteDefaultConnectionStringAsync(Guid id) public async Task GetCurrentTenantCasClientCodeAsync(Guid tenantId) { var tenant = tenantId != Guid.Empty ? await tenantRepository.GetAsync(tenantId) : null; - return tenant?.ExtraProperties.TryGetValue("CasClientCode", out var value) == true ? value?.ToString() : null; + return (tenant?.ExtraProperties.TryGetValue("CasClientCode", out var value) == true ? value?.ToString() : null)!; } public async Task GetCurrentTenantName() @@ -260,4 +274,68 @@ await localEventBus.PublishAsync( } ); } + + [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] + public async Task GetConnectionStringsAsync(Guid id) + { + var tenant = await tenantRepository.GetAsync(id, includeDetails: true); + return new TenantConnectionStringsDto + { + TenantConnectionString = TryDecryptConnectionString( + tenant.FindConnectionString(UnityTenantManagementConsts.TenantConnectionStringName)), + ReadOnlyConnectionString = TryDecryptConnectionString( + tenant.FindConnectionString(UnityTenantManagementConsts.TenantReadOnlyConnectionStringName)) + }; + } + + [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] + public async Task UpdateConnectionStringsAsync(Guid id, TenantConnectionStringsDto input) + { + var tenant = await tenantRepository.GetAsync(id, includeDetails: true); + + if (!string.IsNullOrWhiteSpace(input.TenantConnectionString)) + { + tenant.SetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName, + stringEncryptionService.Encrypt(input.TenantConnectionString)); + } + + if (!string.IsNullOrWhiteSpace(input.ReadOnlyConnectionString)) + { + tenant.SetConnectionString(UnityTenantManagementConsts.TenantReadOnlyConnectionStringName, + stringEncryptionService.Encrypt(input.ReadOnlyConnectionString)); + } + + await tenantRepository.UpdateAsync(tenant); + } + + public async Task> GetManagersAsync(Guid id) + { + using (currentTenant.Change(id)) + { + var users = await IdentityUserRepository.GetListByNormalizedRoleNameAsync("PROGRAM_MANAGER"); + return users.Select(u => + { + var displayName = $"{u.Name} {u.Surname}".Trim(); + return new TenantManagerDto + { + DisplayName = string.IsNullOrEmpty(displayName) ? u.UserName : displayName, + Email = u.Email ?? string.Empty + }; + }).ToList(); + } + } + + private string? TryDecryptConnectionString(string? rawValue) + { + if (rawValue == null) return null; + try + { + var decrypted = stringEncryptionService.Decrypt(rawValue); + return (decrypted != null && decrypted.Contains('=')) ? decrypted : rawValue; + } + catch + { + return rawValue; + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs index 256c2ba724..1877ea9130 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs @@ -74,6 +74,11 @@ public async Task GenerateCredentialsAsync() return new TenantDbCredentials(dbName, dbName, GeneratePassword()); } + public TenantDbCredentials GenerateReadOnlyCredentials(TenantDbCredentials credentials) + { + return new TenantDbCredentials(credentials.DbName, $"{credentials.Username}_readonly", GeneratePassword()); + } + private static string GenerateDbName() { // Format: T_XXX999 where X is A-Z and 9 is 0-9, e.g. T_ABC123 @@ -93,7 +98,7 @@ private static string GenerateDbName() private static string GeneratePassword() { - // 6 random alphanumeric characters (A-Z, 0-9) + // 8 random alphanumeric characters (A-Z, 0-9) Span chars = [ Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], @@ -101,6 +106,8 @@ private static string GeneratePassword() Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], Alphanumeric[Random.Shared.Next(Alphanumeric.Length)] ]; return new string(chars); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj index ece4db4b9b..8746e24419 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj @@ -15,6 +15,7 @@ + @@ -22,5 +23,6 @@ + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs index fc934df332..0e2275ea37 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs @@ -4,6 +4,8 @@ public static class UnityTenantManagementConsts { public const string TenantConnectionStringName = "Tenant"; + public const string TenantReadOnlyConnectionStringName = "Tenant_Readonly"; + public const string TenantLicencePlateExtraPropertyKey = "LicencePlate"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs index 2d806371cf..c0af541474 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs @@ -20,6 +20,7 @@ public override void Map(Tenant source, TenantDto destination) destination.Name = source.Name; destination.ConcurrencyStamp = source.ConcurrencyStamp; destination.CasClientCode = GetExtraProperty(source, "CasClientCode") ?? string.Empty; + destination.LicencePlate = GetExtraProperty(source, "LicencePlate") ?? string.Empty; destination.Division = GetExtraProperty(source, "Division") ?? string.Empty; destination.Branch = GetExtraProperty(source, "Branch") ?? string.Empty; destination.Description = GetExtraProperty(source, "Description") ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs new file mode 100644 index 0000000000..f20da4857f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.TenantManagement.Validation; + +[RemoteService(false)] +[ExposeServices(typeof(IOnboardingValidationStep))] +public class SuperUsersValidationStep(IOnboardingUserLookup userLookup) + : IOnboardingValidationStep, ITransientDependency +{ + public int Order => 20; + public string StepName => "Super Users"; + + public async Task ValidateAsync(OnboardingRequestDto request) + { + var emails = ParseEmails(request.SuperUsers); + if (emails.Length == 0) + return OnboardingValidationStepResult.Failure("No super user email addresses specified."); + + foreach (var email in emails) + { + var guid = await userLookup.FindUserGuidByEmailAsync(email); + if (!string.IsNullOrWhiteSpace(guid)) + return OnboardingValidationStepResult.Success(); + } + + return OnboardingValidationStepResult.Failure( + "None of the specified super user email addresses could be found in the directory."); + } + + internal static string[] ParseEmails(string superUsers) => + [.. superUsers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(e => e.Contains('@'))]; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/TenantNameUniquenessStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/TenantNameUniquenessStep.cs new file mode 100644 index 0000000000..3717b1a57b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/TenantNameUniquenessStep.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.TenantManagement; + +namespace Unity.TenantManagement.Validation; + +[RemoteService(false)] +[ExposeServices(typeof(IOnboardingValidationStep))] +public class TenantNameUniquenessStep(ITenantRepository tenantRepository) + : IOnboardingValidationStep, ITransientDependency +{ + public int Order => 10; + public string StepName => "Tenant Name"; + + public async Task ValidateAsync(OnboardingRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.TenantName)) + return OnboardingValidationStepResult.Failure("Tenant name is required."); + + // FindByNameAsync matches against NormalizedName (stored as ToUpper()) + var existing = await tenantRepository.FindByNameAsync(request.TenantName.ToUpper()); + return existing is not null + ? OnboardingValidationStepResult.Failure($"A tenant named '{request.TenantName}' already exists.") + : OnboardingValidationStepResult.Success(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs index c834e39e03..bdd9379c6d 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs @@ -1,5 +1,7 @@ // This file is part of TenantClientProxy, you can customize it here // ReSharper disable once CheckNamespace +using System; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.Http.Client.ClientProxying; @@ -7,6 +9,14 @@ namespace Unity.TenantManagement; public partial class TenantClientProxy { + public virtual async Task> GetManagersAsync(Guid id) + { + return await RequestAsync>(nameof(GetManagersAsync), new ClientProxyRequestTypeValue + { + { typeof(Guid), id } + }); + } + public virtual async Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) { await RequestAsync(nameof(AssignManagerAsync), new ClientProxyRequestTypeValue @@ -14,4 +24,21 @@ public virtual async Task AssignManagerAsync(TenantAssignManagerDto managerAssig { typeof(TenantAssignManagerDto), managerAssignment } }); } + + public virtual async Task GetConnectionStringsAsync(Guid id) + { + return await RequestAsync(nameof(GetConnectionStringsAsync), new ClientProxyRequestTypeValue + { + { typeof(Guid), id } + }); + } + + public virtual async Task UpdateConnectionStringsAsync(Guid id, TenantConnectionStringsDto input) + { + await RequestAsync(nameof(UpdateConnectionStringsAsync), new ClientProxyRequestTypeValue + { + { typeof(Guid), id }, + { typeof(TenantConnectionStringsDto), input } + }); + } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs new file mode 100644 index 0000000000..857bbda694 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs @@ -0,0 +1,47 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.AspNetCore.Mvc; + +namespace Unity.TenantManagement; + +[Controller] +[RemoteService(Name = TenantManagementRemoteServiceConsts.RemoteServiceName)] +[Area(TenantManagementRemoteServiceConsts.ModuleName)] +[Route("api/multi-tenancy/onboarding-requests")] +public class OnboardingRequestController(IOnboardingRequestAppService onboardingRequestAppService) + : AbpControllerBase, IOnboardingRequestAppService +{ + protected IOnboardingRequestAppService OnboardingRequestAppService { get; } = onboardingRequestAppService; + + [HttpGet] + public virtual Task> GetListAsync(PagedAndSortedResultRequestDto input) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetListAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetListAsync(input); + } + + [HttpGet("{id}")] + public virtual Task GetAsync(Guid id) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetAsync(id); + } + + [HttpGet("{id}/validate")] + public virtual Task ValidateAsync(Guid id) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->ValidateAsync: ModelState Invalid"); + return OnboardingRequestAppService.ValidateAsync(id); + } + + [HttpPost("{id}/create-tenant")] + public virtual Task CreateTenantAsync(Guid id) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->CreateTenantAsync: ModelState Invalid"); + return OnboardingRequestAppService.CreateTenantAsync(id); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs index f87e1706bf..1f6ab9d7a3 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp; @@ -101,6 +102,17 @@ public virtual Task DeleteDefaultConnectionStringAsync(Guid id) return TenantAppService.DeleteDefaultConnectionStringAsync(id); } + [HttpGet] + [Route("{id}/managers")] + public virtual Task> GetManagersAsync(Guid id) + { + if (!ModelState.IsValid) + { + throw new UserFriendlyException("TenantController->GetManagersAsync: ModelState Invalid"); + } + return TenantAppService.GetManagersAsync(id); + } + [HttpPut] [Route("assign-manager")] public virtual Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) @@ -111,4 +123,26 @@ public virtual Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) } return TenantAppService.AssignManagerAsync(managerAssignment); } + + [HttpGet] + [Route("{id}/connection-strings")] + public virtual Task GetConnectionStringsAsync(Guid id) + { + if (!ModelState.IsValid) + { + throw new UserFriendlyException("TenantController->GetConnectionStringsAsync: ModelState Invalid"); + } + return TenantAppService.GetConnectionStringsAsync(id); + } + + [HttpPut] + [Route("{id}/connection-strings")] + public virtual Task UpdateConnectionStringsAsync(Guid id, [FromBody] TenantConnectionStringsDto input) + { + if (!ModelState.IsValid) + { + throw new UserFriendlyException("TenantController->UpdateConnectionStringsAsync: ModelState Invalid"); + } + return TenantAppService.UpdateConnectionStringsAsync(id, input); + } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs index 519a5fbeee..54c9e44dea 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Permissions; using Volo.Abp.TenantManagement.Localization; using Volo.Abp.UI.Navigation; using Volo.Abp.Authorization.Permissions; @@ -21,7 +22,10 @@ public virtual Task ConfigureMenuAsync(MenuConfigurationContext context) var tenantManagementMenuItem = new ApplicationMenuItem(TenantManagementMenuNames.GroupName, l["Menu:TenantManagement"], icon: "fa fa-users"); administrationMenu.AddItem(tenantManagementMenuItem); - tenantManagementMenuItem.AddItem(new ApplicationMenuItem(TenantManagementMenuNames.Tenants, l["Tenants"], url: "~/TenantManagement/Tenants").RequirePermissions(TenantManagementPermissions.Tenants.Default)); + tenantManagementMenuItem.AddItem( + new ApplicationMenuItem(TenantManagementMenuNames.Tenants, l["Tenants"], url: "~/TenantManagement/Tenants") + .RequirePermissions(TenantManagementPermissions.Tenants.Default, IdentityConsts.ITOperationsPermissionName) + ); return Task.CompletedTask; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml new file mode 100644 index 0000000000..653907fc98 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml @@ -0,0 +1,54 @@ +@page +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@using Unity.TenantManagement +@using Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding +@model CreateTenantModalModel +@{ + Layout = null; +} + + + + +
+
Ministry
+
@Model.OnboardingRequest.Ministry
+
Branch
+
@Model.OnboardingRequest.Branch
+
Program Area
+
@Model.OnboardingRequest.ProgramAreaName
+
Category
+
@Model.OnboardingRequest.Category
+
Features
+
@Model.OnboardingRequest.Features
+
+ +
+ +
+ + Validating request... +
+
+ +
+
+ + Creating tenant, please wait... +
+
+ This may take a moment. + Tenant provisioning is in progress — please do not close this window or navigate away until the process completes. +
+
+ +
+ + + + +
diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs new file mode 100644 index 0000000000..bf3d068f36 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs @@ -0,0 +1,25 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Unity.Modules.Shared.Permissions; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class CreateTenantModalModel(IOnboardingRequestAppService onboardingRequestAppService) + : OnboardingPageModel +{ + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + public OnboardingRequestDto? OnboardingRequest { get; set; } + + public virtual async Task OnGetAsync() + { + OnboardingRequest = await onboardingRequestAppService.GetAsync(Id); + if (OnboardingRequest == null) return NotFound(); + return Page(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml new file mode 100644 index 0000000000..db62e196e9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml @@ -0,0 +1,41 @@ +@page +@using Unity.TenantManagement.Web.Navigation +@using Volo.Abp.AspNetCore.Mvc.UI.Layout +@using Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding +@model IndexModel +@inject IPageLayout PageLayout +@{ + PageLayout.Content.BreadCrumb.Add("Onboarding"); + PageLayout.Content.MenuItemName = TenantManagementMenuNames.Onboarding; + ViewBag.PageTitle = "Onboarding"; +} +@section styles { + + +} +@section scripts { + + + + +} +
+
+
+ +
+
+ + + +
+
+
+ +
diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml.cs new file mode 100644 index 0000000000..ce8af35a00 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class IndexModel : OnboardingPageModel +{ + public void OnGet() { } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js new file mode 100644 index 0000000000..31bbf32c94 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -0,0 +1,156 @@ +(function () { + let _onboardingRequestAppService = unity.tenantManagement.onboardingRequest; + let _dataTable = null; + let _selectedRow = null; + + let _createTenantModal = new abp.ModalManager({ + viewUrl: abp.appPath + 'TenantManagement/Onboarding/CreateTenantModal', + modalClass: 'createTenantModal' + }); + + function _renderValidationResult(result) { + $('#onboarding-validation-loading').addClass('d-none'); + if (result.isValid) { + $('#onboarding-validation-result') + .html('
Validation passed. Ready to create tenant.
') + .removeClass('d-none'); + $('#btn-confirm-create-tenant').prop('disabled', false); + } else { + let issuesHtml = (result.issues || []) + .map(function (i) { return '
  • ' + $('').text(i).html() + '
  • '; }) + .join(''); + $('#onboarding-validation-result') + .html('
    Validation failed:
      ' + issuesHtml + '
    ') + .removeClass('d-none'); + } + } + + function _renderValidationFail() { + $('#onboarding-validation-loading').addClass('d-none'); + $('#onboarding-validation-result') + .html('
    Failed to validate request. Please try again.
    ') + .removeClass('d-none'); + } + + function _onCreateTenantConfirm(applicationId) { + return function () { + let $btn = $(this); + $btn.prop('disabled', true); + $('#btn-cancel-create-tenant').prop('disabled', true); + $('#onboarding-creating').removeClass('d-none'); + + abp.ajax({ + url: abp.appPath + 'api/multi-tenancy/onboarding-requests/' + applicationId + '/create-tenant', + type: 'POST' + }).done(function () { + abp.notify.success('Tenant created successfully.'); + _createTenantModal.close(); + _dataTable.ajax.reloadEx(); + }).fail(function () { + $('#onboarding-creating').addClass('d-none'); + $btn.prop('disabled', false); + $('#btn-cancel-create-tenant').prop('disabled', false); + abp.notify.error('Failed to create tenant. Please try again.'); + }); + }; + } + + abp.modals.createTenantModal = function () { + return { + initModal: function (publicApi, args) { + let applicationId = args.id; + + abp.ajax({ + url: abp.appPath + 'api/multi-tenancy/onboarding-requests/' + applicationId + '/validate', + type: 'GET' + }).done(_renderValidationResult).fail(_renderValidationFail); + + $('#btn-confirm-create-tenant').on('click', _onCreateTenantConfirm(applicationId)); + } + }; + }; + + let listColumns = [ + { title: 'Tenant Name', data: 'tenantName', name: 'tenantName', index: 0 }, + { title: 'Tenant Description', data: 'tenantDescription', name: 'tenantDescription', index: 1 }, + { title: 'Program Area Name', data: 'programAreaName', name: 'programAreaName', index: 2 }, + { title: 'Program Area Description', data: 'programAreaDescription', name: 'programAreaDescription', index: 3 }, + { title: 'Contact(s)', data: 'contacts', name: 'contacts', index: 4 }, + { title: 'Features', data: 'features', name: 'features', index: 5 }, + { title: 'Super Users', data: 'superUsers', name: 'superUsers', index: 6 }, + { title: 'Executive Director', data: 'executiveDirector', name: 'executiveDirector', index: 7 }, + { title: 'Branch', data: 'branch', name: 'branch', index: 8 }, + { title: 'Ministry', data: 'ministry', name: 'ministry', index: 9 }, + { title: 'Status', data: 'status', name: 'status', index: 10 }, + { title: 'Category', data: 'category', name: 'category', index: 11 }, + { + title: 'Submission Date', + data: 'submissionDate', + name: 'submissionDate', + index: 12, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + } + ]; + + let responseCallback = function (result) { + return { + recordsTotal: result.totalCount, + recordsFiltered: result.totalCount, + data: result.items + }; + }; + + function manageActionButtons() { + const hasSelection = _selectedRow !== null; + $('#btn-open-onboarding').toggleClass('action-bar-btn-unavailable', !hasSelection); + $('#btn-create-tenant').toggleClass('action-bar-btn-unavailable', + !hasSelection || _selectedRow.status !== 'Approved'); + } + + $(function () { + _dataTable = initializeDataTable({ + dt: $('#OnboardingRequestsTable'), + listColumns: listColumns, + defaultSortColumn: 0, + dataEndpoint: _onboardingRequestAppService.getList, + data: { maxResultCount: 1000 }, + responseCallback: responseCallback, + actionButtons: commonTableActionButtons('Onboarding Requests').filter(b => b.id !== 'btn-toggle-filter'), + serverSideEnabled: false, + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dynamicButtonContainerId: 'dynamicButtonContainerId', + externalSearchId: 'search' + }); + + _dataTable.select.style('single'); + + _dataTable.on('select', function (e, dt, type, indexes) { + if (type === 'row' && indexes.length) { + _selectedRow = _dataTable.row(indexes[0]).data(); + manageActionButtons(); + } + }); + + _dataTable.on('deselect', function () { + if (_dataTable.rows({ selected: true }).count() === 0) { + _selectedRow = null; + manageActionButtons(); + } + }); + + $('#btn-open-onboarding').on('click', function () { + if (!_selectedRow) return; + // TBD: open the onboarding request details + console.log('Open onboarding request:', _selectedRow); + }); + + $('#btn-create-tenant').on('click', function () { + if (!_selectedRow) return; + _createTenantModal.open({ id: _selectedRow.id }); + }); + }); +})(); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/OnboardingPageModel.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/OnboardingPageModel.cs new file mode 100644 index 0000000000..06692db6a7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/OnboardingPageModel.cs @@ -0,0 +1,12 @@ +using Unity.TenantManagement.Web; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; + +public abstract class OnboardingPageModel : AbpPageModel +{ + protected OnboardingPageModel() + { + ObjectMapperContext = typeof(UnityTenantManagementWebModule); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/_ViewImports.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/_ViewImports.cshtml new file mode 100644 index 0000000000..231948b339 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml new file mode 100644 index 0000000000..7dca6921d8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml @@ -0,0 +1,194 @@ +@page +@using Microsoft.AspNetCore.Mvc.Localization +@using Microsoft.Extensions.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@using Volo.Abp.Data +@using Volo.Abp.Localization +@using Volo.Abp.ObjectExtending +@using Volo.Abp.TenantManagement.Localization +@using Unity.TenantManagement +@using Unity.TenantManagement.Web.Pages.TenantManagement.Tenants +@model ConfigurationModalModel +@inject IHtmlLocalizer L +@inject IStringLocalizerFactory StringLocalizerFactory +@{ + Layout = null; +} +
    + + + + + + + + +
    + +
    + + + + + +
    + + +
    + + @foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties().Where(p => !p.Name.EndsWith("_Text"))) + { + if (propertyInfo.Type.IsEnum || !propertyInfo.Lookup.Url.IsNullOrEmpty()) + { + if (propertyInfo.Type.IsEnum) + { + Model.Tenant.ExtraProperties.ToEnum(propertyInfo.Name, propertyInfo.Type); + } + + } + else + { + + } + } +
    + + @if (Model.CanManageManagers) + { +
    + +
    +
    + Current Program Managers + +
    +
    + Loading... +
    +
    +
    + +
    + +
    Assign Program Manager
    +
    + + +
    + + + + + + +
    + } + + @if (Model.CanManageConnectionStrings) + { +
    +
    + + + Displayed decrypted. Saved encrypted automatically. +
    +
    + + + Displayed decrypted. Saved encrypted automatically. +
    +
    + } + + @if (Model.CanManageFeatures) + { +
    + +
    + +
    + } + +
    +
    + + + + +
    +
    diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs new file mode 100644 index 0000000000..3f5c9cd4c2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs @@ -0,0 +1,155 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Unity.GrantManager.Integrations; +using Unity.Modules.Shared.Permissions; +using Volo.Abp.Domain.Entities; +using Volo.Abp.FeatureManagement; +using Volo.Abp.Features; +using Volo.Abp.ObjectExtending; +using Volo.Abp.TenantManagement; +using Volo.Abp.Validation; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Tenants; + +public class ConfigurationModalModel( + ITenantAppService tenantAppService, + ICasClientCodeLookupService lookupService, + IFeatureAppService featureAppService) : TenantManagementPageModel +{ + [BindProperty] + public TenantInfoModel Tenant { get; set; } = null!; + + [BindProperty] + public TenantConnectionStringsDto ConnectionStrings { get; set; } = new(); + + [BindProperty] + public string? SelectedManagerUserIdentifier { get; set; } + + [BindProperty] + public string? FeaturesJson { get; set; } + + public ManagerInfoModel Manager { get; set; } = null!; + + public List CasClientOptions { get; set; } = []; + + public bool CanManageConnectionStrings { get; set; } + + public bool CanManageFeatures { get; set; } + + public bool CanManageManagers { get; set; } + + public virtual async Task OnGetAsync(Guid id) + { + var tenantDto = await tenantAppService.GetAsync(id); + + Tenant = ObjectMapper.Map(tenantDto); + Manager = ObjectMapper.Map(tenantDto); + CasClientOptions = await lookupService.GetActiveOptionsAsync(); + + CanManageConnectionStrings = (await AuthorizationService + .AuthorizeAsync(User, TenantManagementPermissions.Tenants.ManageConnectionStrings)).Succeeded; + + CanManageFeatures = (await AuthorizationService + .AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded; + + CanManageManagers = CanManageFeatures; + + if (CanManageConnectionStrings) + { + ConnectionStrings = await tenantAppService.GetConnectionStringsAsync(id); + } + + return Page(); + } + + public virtual async Task OnPostAsync() + { + ValidateModel(); + + var input = ObjectMapper.Map(Tenant); + await tenantAppService.UpdateAsync(Tenant.Id, input); + + if (!string.IsNullOrWhiteSpace(SelectedManagerUserIdentifier)) + { + await tenantAppService.AssignManagerAsync(new TenantAssignManagerDto + { + TenantId = Tenant.Id, + UserIdentifier = SelectedManagerUserIdentifier + }); + } + + if ((await AuthorizationService.AuthorizeAsync(User, TenantManagementPermissions.Tenants.ManageConnectionStrings)).Succeeded) + { + await tenantAppService.UpdateConnectionStringsAsync(Tenant.Id, ConnectionStrings); + } + + if (!string.IsNullOrEmpty(FeaturesJson) && + (await AuthorizationService.AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded) + { + var featureUpdates = new List(); + foreach (var feature in JsonDocument.Parse(FeaturesJson).RootElement.EnumerateArray()) + { + featureUpdates.Add(new UpdateFeatureDto + { + Name = feature.GetProperty("name").GetString()!, + Value = feature.GetProperty("value").GetString()! + }); + } + await featureAppService.UpdateAsync( + TenantFeatureValueProvider.ProviderName, + Tenant.Id.ToString(), + new UpdateFeaturesDto { Features = featureUpdates }); + } + + return NoContent(); + } + + public class TenantInfoModel : ExtensibleObject, IHasConcurrencyStamp + { + [HiddenInput] + public Guid Id { get; set; } + + [Required] + [DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))] + [Display(Name = "DisplayName:TenantName")] + public string Name { get; set; } = string.Empty; + + public string Division { get; set; } = string.Empty; + public string Branch { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + [Display(Name = "CAS Client Code")] + public string? CasClientCode { get; set; } + + [HiddenInput] + public string ConcurrencyStamp { get; set; } = string.Empty; + } + + public class ManagerInfoModel : ExtensibleObject + { + [HiddenInput] + public Guid Id { get; set; } + + [Display(Name = "DisplayName:TenantName")] + public string Name { get; set; } = string.Empty; + + [DisplayName("First Name")] + public string? FirstName { get; set; } + + [DisplayName("Last Name")] + public string? LastName { get; set; } + + [Required] + public string Directory { get; set; } = "IDIR"; + + [Required] + public string UserIdentifier { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml index 4d492e9bd1..3ce114cbdc 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml @@ -19,8 +19,9 @@ ViewBag.PageTitle = L["Tenants"].Value; } @section styles { - + + } @section scripts { @@ -33,9 +34,16 @@ @section content_toolbar { @await Component.InvokeAsync(typeof(AbpPageToolbarViewComponent), new { pageName = typeof(IndexModel).FullName }) } - - - - - - +
    +
    +
    + +
    +
    + +
    +
    +
    + + +
    diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js index cc16f58fd0..ca643d9587 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js @@ -4,154 +4,86 @@ let _userImportService = unity.grantManager.identity.userImport; let _casClientCodeHash = {}; - let _editModal = new abp.ModalManager( - abp.appPath + 'TenantManagement/Tenants/EditModal' - ); let _createModal = new abp.ModalManager({ viewUrl: abp.appPath + 'TenantManagement/Tenants/CreateModal', modalClass: 'createTenant' } ); - let _featuresModal = new abp.ModalManager( - abp.appPath + 'FeatureManagement/FeatureManagementModal' - ); - let _assignManagerModal = new abp.ModalManager({ - viewUrl: abp.appPath + 'TenantManagement/Tenants/AssignManagerModal', - modalClass: 'assignManager' + + let _configurationModal = new abp.ModalManager({ + viewUrl: abp.appPath + 'TenantManagement/Tenants/ConfigurationModal', + modalClass: 'configurationModal' } ); let _dataTable = null; - abp.ui.extensions.entityActions.get('tenantManagement.tenant').addContributor( - function(actionList) { - return actionList.addManyTail( - [ - { - text: l('Edit'), - visible: abp.auth.isGranted( - 'UnityTenantManagement.Tenants.Update' - ), - action: function (data) { - _editModal.open({ - id: data.record.id, - }); - }, - }, - { - text: l('Assign Manager'), - visible: abp.auth.isGranted( - 'UnityTenantManagement.Tenants.Create' - ), - action: function (data) { - _assignManagerModal.open({ - id: data.record.id, - }); - }, - }, - { - text: l('Features'), - visible: abp.auth.isGranted( - 'AbpTenantManagement.Tenants.ManageFeatures' - ), - action: function (data) { - _featuresModal.open({ - providerName: 'T', - providerKey: data.record.id, - }); - }, - }, - { - text: l('Delete'), - visible: abp.auth.isGranted( - 'UnityTenantManagement.Tenants.Delete' - ), - confirmMessage: function (data) { - return l( - 'TenantDeletionConfirmationMessage', - data.record.name - ); - }, - action: function (data) { - _tenantAppService - .delete(data.record.id) - .then(function () { - _dataTable.ajax.reloadEx(); - abp.notify.success(l('SuccessfullyDeleted')); - }); - }, - } - ] - ); + // ─── Actions column renderer ────────────────────────────────────────────── + + function _buildActionsCell(id, name) { + let items = []; + if (abp.auth.isGranted('UnityTenantManagement.Tenants.Update') || abp.auth.isGranted('ITOperations')) { + items.push('Configuration'); } - ); + if (abp.auth.isGranted('UnityTenantManagement.Tenants.Delete')) { + items.push('' + l('Delete') + ''); + } + if (!items.length) return ''; + return '
    '; + } - abp.ui.extensions.tableColumns.get('tenantManagement.tenant').addContributor( - function (columnList) { - columnList.addManyTail( - [ - { - title: l("Actions"), - orderable: false, - rowAction: { - items: abp.ui.extensions.entityActions.get('tenantManagement.tenant').actions.toArray() - } - }, - { - title: l("TenantName"), - data: 'name', - }, - { - title: l("Division"), - data: 'division', - }, - { - title: l("Branch"), - data: 'branch', - }, - { - title: l("Description"), - data: 'description', - }, - { - title: "CAS Client Code", - data: 'casClientCode', - render: function (data, type, row) { - if (type === 'display') { - const code = row.casClientCode || ''; - const displayValue = _casClientCodeHash[code] || ''; - return displayValue; - } - return data; - } - }, - { - title: l("Id"), - data: 'id', - } - ] - ); + // ─── Column definitions ─────────────────────────────────────────────────── + + let listColumns = [ + { + title: l('Actions'), + name: 'actions', + data: 'id', + orderable: false, + className: 'notexport text-center', + index: 0, + render: function (data, type, row) { + return type === 'display' ? _buildActionsCell(data, row.name) : ''; + } }, - 0 //adds as the first contributor - ); + { title: l('TenantName'), data: 'name', name: 'name', index: 1 }, + { title: 'Licence Plate', data: 'licencePlate', name: 'licencePlate', index: 2 }, + { title: l('Division'), data: 'division', name: 'division', index: 3 }, + { title: l('Branch'), data: 'branch', name: 'branch', index: 4 }, + { title: l('Description'), data: 'description', name: 'description', index: 5 }, + { + title: 'CAS Client Code', + data: 'casClientCode', + name: 'casClientCode', + index: 6, + render: function (data, type, row) { + if (type === 'display') { + return _casClientCodeHash[row.casClientCode || ''] || ''; + } + return data; + } + }, + { title: l('Id'), data: 'id', name: 'id', index: 7 } + ]; - let inputAction = function (requestData, dataTableSettings) { - return { - directory: 'IDIR', - firstName: $('#create-tenant-firstName').val(), - lastName: $('#create-tenant-lastName').val() - }; - }; + let defaultVisibleColumns = ['actions', 'name', 'licencePlate', 'division', 'branch', 'description', 'casClientCode']; let responseCallback = function (result) { return { - recordsTotal: result.length, - recordsFiltered: result.length, - data: result + recordsTotal: result.totalCount, + recordsFiltered: result.totalCount, + data: result.items }; }; + // ─── Modal setup: Create tenant ─────────────────────────────────────────── + let _filterDataTable = null; + let _configFilterDataTable = null; let setupCreateTenantModal = function () { let _$filterTable = $('#UserSearchTable'); @@ -166,8 +98,20 @@ searching: false, ajax: abp.libs.datatables.createAjax( _userImportService.search, - inputAction, - responseCallback + function () { + return { + directory: 'IDIR', + firstName: $('#create-tenant-firstName').val(), + lastName: $('#create-tenant-lastName').val() + }; + }, + function (result) { + return { + recordsTotal: result.length, + recordsFiltered: result.length, + data: result + }; + } ), select: { style: 'single', @@ -191,7 +135,7 @@ className: 'data-table-header' }], }) - ) + ); $('#TenantAdminSearchButton').click(function (e) { e.preventDefault(); @@ -201,7 +145,6 @@ $('#cancel-tenant-btn').click(function (e) { _createModal.close(); - _assignManagerModal.close(); }); _filterDataTable.on('select', function (e, dt, type, indexes) { @@ -216,15 +159,7 @@ $('#create-tenant-admin-id').val(); $('#create-tenant-btn').attr('disabled', true); }); - - _createModal.onResult(function () { - _dataTable.ajax.reloadEx(); - }); - - _assignManagerModal.onResult(function () { - _dataTable.ajax.reloadEx(); - }); - } + }; _createModal.onOpen(function () { setTimeout(() => { @@ -232,29 +167,252 @@ }); }); - _assignManagerModal.onOpen(function () { + _configurationModal.onOpen(function () { setTimeout(() => { - _filterDataTable.columns.adjust().draw(); + if (_configFilterDataTable) { + _configFilterDataTable.columns.adjust().draw(); + } }); }); + function _createTenantInitModal(publicApi, args) { + setupCreateTenantModal(); + } + abp.modals.createTenant = function () { - let initModal = function (publicApi, args) { - setupCreateTenantModal(); + return { initModal: _createTenantInitModal }; + }; + + // ─── Modal setup: Configuration ─────────────────────────────────────────── + + let _configTenantId = null; + let _featuresLoaded = false; + + function _renderFeatureItem(feature) { + let id = 'ft-' + feature.name.replaceAll('.', '-'); + let checked = feature.value === 'true' ? ' checked' : ''; + return '
    ' + + '' + + '' + + '
    '; + } + + function _renderFeatureGroups(groups) { + if (!groups?.length) { + return '

    No features available.

    '; + } + let html = ''; + groups.forEach(function (group) { + html += '
    '; + html += '
    ' + (group.displayName || group.name) + '
    '; + group.features?.forEach(function (feature) { + html += _renderFeatureItem(feature); + }); + html += '
    '; + }); + return html; + } + + function _loadManagersTab(tenantId) { + $('#config-managers-loading').show(); + $('#config-managers-content').html(''); + + abp.ajax({ + url: abp.appPath + 'api/multi-tenancy/tenants/' + tenantId + '/managers', + type: 'GET' + }).done(function (result) { + $('#config-managers-loading').hide(); + $('#config-managers-count').text(result?.length ?? 0); + if (result?.length) { + let html = '
      '; + result.forEach(function (m) { + html += '
    • ' + + '' + + '' + $('').text(m.displayName).html() + '' + + (m.email ? '(' + $('').text(m.email).html() + ')' : '') + + '
    • '; + }); + html += '
    '; + $('#config-managers-content').html(html); + } else { + $('#config-managers-content').html('

    No program managers assigned.

    '); + } + }).fail(function () { + $('#config-managers-loading').hide(); + $('#config-managers-content').html('

    Failed to load program managers.

    '); + }); + } + + function _loadFeaturesTab(tenantId) { + $('#config-features-loading').show(); + $('#config-features-content').html(''); + $('#config-features-actions').hide(); + + abp.ajax({ + url: abp.appPath + 'api/feature-management/features', + type: 'GET', + data: { providerName: 'T', providerKey: tenantId } + }).done(function (result) { + $('#config-features-loading').hide(); + $('#config-features-content').html(_renderFeatureGroups(result.groups)); + $('#config-features-actions').show(); + _captureFeaturesToForm(); + }).fail(function () { + $('#config-features-loading').hide(); + $('#config-features-content').html('
    Failed to load features. Please try again.
    '); + }); + } + + function _captureFeaturesToForm() { + if (!_featuresLoaded) return; + let features = []; + $('#config-features-content input[type="checkbox"]').each(function () { + features.push({ name: $(this).data('feature-name'), value: $(this).prop('checked').toString() }); + }); + $('#config-features-json').val(JSON.stringify(features)); + } + + function _specializationCheckboxChange() { + if ($(this).prop('checked')) { + let $allSpecs = $('[data-feature-group="Specializations"] input[type="checkbox"]'); + $allSpecs.not(this).prop('checked', false); + } + } + + function _configSearchInputAction() { + let field = $('#config-search-field').val(); + let value = $('#config-search-value').val(); + if (field === 'firstAndLast') { + let parts = value.trim().replace(/\s+/g, ' ').split(' '); + return { + directory: 'IDIR', + firstName: parts[0] || '', + lastName: parts[1] || '', + email: '' + }; + } + return { + directory: 'IDIR', + firstName: field === 'firstName' ? value : '', + lastName: field === 'lastName' ? value : '', + email: field === 'email' ? value : '' }; - return { initModal: initModal }; } - abp.modals.assignManager = function () { - let initModal = function (publicApi, args) { - setupCreateTenantModal(); + function _configSearchResponseCallback(result) { + return { recordsTotal: result.length, recordsFiltered: result.length, data: result }; + } + + function _configurationModalInitModal(publicApi, args) { + _configTenantId = args.id; + + _loadManagersTab(_configTenantId); + + _configFilterDataTable = $('#ConfigUserSearchTable').DataTable( + abp.libs.datatables.normalizeConfiguration({ + order: [[0, 'asc']], + processing: true, + serverSide: false, + scrollX: true, + paging: true, + searching: false, + ajax: abp.libs.datatables.createAjax( + _userImportService.search, + _configSearchInputAction, + _configSearchResponseCallback + ), + select: { style: 'single' }, + columnDefs: [ + { title: 'First Name', name: 'firstName', data: 'firstName', className: 'data-table-header' }, + { title: 'Last Name', name: 'lastName', data: 'lastName', className: 'data-table-header' }, + { title: 'Display Name', name: 'displayName', data: 'displayName', className: 'data-table-header' }, + { title: 'Email', name: 'email', data: 'email', className: 'data-table-header' } + ] + }) + ); + + $('#config-search-field').on('change', function () { + let placeholders = { + firstName: 'At least 2 characters...', + lastName: 'At least 2 characters...', + firstAndLast: 'e.g. John Smith', + email: 'At least 2 characters...' + }; + $('#config-search-value').val('').attr('placeholder', placeholders[$(this).val()] || 'At least 2 characters...'); + }); + + $('#ConfigTenantAdminSearchButton').click(function (e) { + e.preventDefault(); + if ($('#config-search-value').val().trim().length < 2) { + abp.notify.warn('Please enter at least 2 characters to search.'); + return; + } + _configFilterDataTable.ajax.reloadEx(); + $('#config-selected-user-identifier').val(''); + $('#config-selected-user-display').hide(); + }); + + _configFilterDataTable.on('select', function (e, dt, type, indexes) { + if (type === 'row') { + let selectedData = _configFilterDataTable.row(indexes).data(); + $('#config-selected-user-identifier').val(selectedData.userGuid); + let displayName = selectedData.displayName || (selectedData.firstName + ' ' + selectedData.lastName).trim(); + $('#config-selected-user-name').text(displayName); + $('#config-selected-user-display').show(); + } + }); + + _configFilterDataTable.on('deselect', function () { + $('#config-selected-user-identifier').val(''); + $('#config-selected-user-display').hide(); + }); + + _featuresLoaded = false; + $('#tab-features').on('shown.bs.tab', function () { + if (!_featuresLoaded) { + _featuresLoaded = true; + _loadFeaturesTab(_configTenantId); + } + }); + $('#config-features-content').on('change', '[data-feature-group="Specializations"] input[type="checkbox"]', _specializationCheckboxChange); + $('#config-features-content').on('change', 'input[type="checkbox"]', _captureFeaturesToForm); + + $('#pane-features').closest('form').on('invalid-form.validate', function (e, validator) { + if (validator.errorList.length > 0) { + let $firstErrorPane = $(validator.errorList[0].element).closest('.tab-pane'); + if ($firstErrorPane.length) { + $('[data-bs-target="#' + $firstErrorPane.attr('id') + '"]').tab('show'); + } + } + }); + } + + abp.modals.configurationModal = function () { + return { initModal: _configurationModalInitModal }; + }; + + // ─── Delete confirmation ────────────────────────────────────────────────── + + function _onDeleteConfirmed(id) { + return function (confirmed) { + if (confirmed) { + _tenantAppService.delete(id).then(function () { + _dataTable.ajax.reloadEx(); + abp.notify.success(l('SuccessfullyDeleted')); + }); + } }; - return { initModal: initModal }; } + function _confirmDeleteTenant(id, name) { + abp.message.confirm(l('TenantDeletionConfirmationMessage', name), _onDeleteConfirmed(id)); + } + + // ─── Document ready ─────────────────────────────────────────────────────── + $(function () { - let _$wrapper = $('#TenantsWrapper'); - // Parse CAS client code hash from hidden field data attribute let casClientCodeHashEl = document.getElementById('casClientCodeHashData'); try { @@ -263,23 +421,28 @@ console.warn('Failed to parse CAS client code hash', e); } - _dataTable = _$wrapper.find('table').DataTable( - abp.libs.datatables.normalizeConfiguration({ - order: [[1, 'asc']], - processing: true, - paging: true, - scrollX: true, - serverSide: true, - ajax: abp.libs.datatables.createAjax(_tenantAppService.getList), - columnDefs: abp.ui.extensions.tableColumns.get('tenantManagement.tenant').columns.toArray(), - }) - ); + _dataTable = initializeDataTable({ + dt: $('#TenantsTable'), + listColumns: listColumns, + defaultVisibleColumns: defaultVisibleColumns, + defaultSortColumn: 1, + dataEndpoint: _tenantAppService.getList, + data: { maxResultCount: 1000 }, + responseCallback: responseCallback, + actionButtons: commonTableActionButtons('Tenants').filter(function (b) { return b.id !== 'btn-toggle-filter'; }), + serverSideEnabled: false, + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dynamicButtonContainerId: 'dynamicButtonContainerId', + externalSearchId: 'search' + }); _createModal.onResult(function () { _dataTable.ajax.reloadEx(); }); - _editModal.onResult(function () { + _configurationModal.onResult(function () { _dataTable.ajax.reloadEx(); }); @@ -287,14 +450,25 @@ e.preventDefault(); _createModal.open(); }); + + // Action column event delegation + $(document).on('click', '.tenant-action-config', function (e) { + e.preventDefault(); + _configurationModal.open({ id: $(this).data('id') }); + }); + + $(document).on('click', '.tenant-action-delete', function (e) { + e.preventDefault(); + _confirmDeleteTenant($(this).data('id'), $(this).data('name')); + }); }); - - // Use event delegation to handle dynamically loaded elements + + // ─── CAS client select handler (event delegation for dynamic elements) ──── + $(document).on('change', '.cas-client-select', function() { const $select = $(this); const selectedOption = $select.find('option:selected'); - - // Handle ministry field update + const ministryValue = selectedOption.data('ministry') || ''; const ministryTarget = $select.data('ministry-target'); if (ministryTarget) { @@ -303,8 +477,7 @@ $targetInput.val(ministryValue); } } - - // Handle CAS client code update + const casClientCode = selectedOption.data('cas-client-code'); if (casClientCode) { const $container = $select.closest('form, .modal-body'); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs index 2ff26b3542..07827e6ab9 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs @@ -1,6 +1,7 @@ using Unity.TenantManagement.Web.Pages.TenantManagement.Tenants; using Volo.Abp.Mapperly; using static Unity.TenantManagement.Web.Pages.TenantManagement.Tenants.AssignManagerModalModel; +using static Unity.TenantManagement.Web.Pages.TenantManagement.Tenants.ConfigurationModalModel; namespace Unity.TenantManagement.Web; @@ -79,6 +80,66 @@ public override void Map(EditModalModel.TenantInfoModel source, TenantUpdateDto } } +public class TenantDtoToConfigurationTenantInfoMapper : MapperBase +{ + public override TenantInfoModel Map(TenantDto source) + { + var destination = new TenantInfoModel(); + Map(source, destination); + return destination; + } + + public override void Map(TenantDto source, TenantInfoModel destination) + { + destination.Id = source.Id; + destination.Name = source.Name; + destination.Division = source.Division; + destination.Branch = source.Branch; + destination.Description = source.Description; + destination.CasClientCode = source.CasClientCode; + destination.ConcurrencyStamp = source.ConcurrencyStamp; + TenantExtraPropertiesCopier.Copy(source, destination); + } +} + +public class ConfigurationTenantInfoToTenantUpdateDtoMapper : MapperBase +{ + public override TenantUpdateDto Map(TenantInfoModel source) + { + var destination = new TenantUpdateDto(); + Map(source, destination); + return destination; + } + + public override void Map(TenantInfoModel source, TenantUpdateDto destination) + { + destination.Name = source.Name; + destination.Division = source.Division; + destination.Branch = source.Branch; + destination.Description = source.Description; + destination.CasClientCode = source.CasClientCode ?? string.Empty; + destination.ConcurrencyStamp = source.ConcurrencyStamp; + TenantExtraPropertiesCopier.Copy(source, destination); + } +} + +public class TenantDtoToConfigurationManagerInfoMapper : MapperBase +{ + public override ManagerInfoModel Map(TenantDto source) + { + var destination = new ManagerInfoModel(); + Map(source, destination); + return destination; + } + + public override void Map(TenantDto source, ManagerInfoModel destination) + { + destination.Id = source.Id; + destination.Name = source.Name; + TenantExtraPropertiesCopier.Copy(source, destination); + } +} + public class TenantDtoToAssignManagerInfoMapper : MapperBase { public override AssignManagerInfoModel Map(TenantDto source) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs index 08f08f4142..f7bffd53c2 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs @@ -14,6 +14,7 @@ using Volo.Abp.UI.Navigation; using Volo.Abp.VirtualFileSystem; using Volo.Abp.Threading; +using Unity.Modules.Shared.Permissions; using Unity.TenantManagement.Web.Navigation; namespace Unity.TenantManagement.Web; @@ -55,10 +56,12 @@ public override void ConfigureServices(ServiceConfigurationContext context) Configure(options => { - options.Conventions.AuthorizePage("/TenantManagement/Tenants/Index", TenantManagementPermissions.Tenants.Default); + options.Conventions.AuthorizePage("/TenantManagement/Tenants/Index", TenantManagementPermissions.Policies.TenantsOrITOps); options.Conventions.AuthorizePage("/TenantManagement/Tenants/CreateModal", TenantManagementPermissions.Tenants.Create); options.Conventions.AuthorizePage("/TenantManagement/Tenants/EditModal", TenantManagementPermissions.Tenants.Update); options.Conventions.AuthorizePage("/TenantManagement/Tenants/AssignManagerModal", TenantManagementPermissions.Tenants.Create); + options.Conventions.AuthorizePage("/TenantManagement/Tenants/ConfigurationModal", TenantManagementPermissions.Policies.TenantsOrITOps); + options.Conventions.AuthorizePage("/TenantManagement/Onboarding/Index", IdentityConsts.ITOperationsPermissionName); }); Configure(options => @@ -95,7 +98,8 @@ public override void PostConfigureServices(ServiceConfigurationContext context) editFormTypes: new[] { typeof(Pages.TenantManagement.Tenants.EditModalModel.TenantInfoModel), - typeof(Pages.TenantManagement.Tenants.AssignManagerModalModel.AssignManagerInfoModel) + typeof(Pages.TenantManagement.Tenants.AssignManagerModalModel.AssignManagerInfoModel), + typeof(Pages.TenantManagement.Tenants.ConfigurationModalModel.TenantInfoModel) } ); }); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js index 7ffac83e74..c242f8b161 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js @@ -64,6 +64,34 @@ dataType: null }, ajaxParams)); }; + unity.tenantManagement.tenant.getConnectionStrings = function(id, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/connection-strings', + type: 'GET' + }, ajaxParams)); + }; + unity.tenantManagement.tenant.updateConnectionStrings = function(id, input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/connection-strings', + type: 'PUT', + data: JSON.stringify(input) + }, ajaxParams)); + }; + + })(); + + // controller unity.tenantManagement.onboardingRequest + + (function(){ + + abp.utils.createNamespace(window, 'unity.tenantManagement.onboardingRequest'); + + unity.tenantManagement.onboardingRequest.getList = function(input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/multi-tenancy/onboarding-requests' + abp.utils.buildQueryString([{ name: 'sorting', value: input.sorting }, { name: 'skipCount', value: input.skipCount }, { name: 'maxResultCount', value: input.maxResultCount }]) + '', + type: 'GET' + }, ajaxParams)); + }; })(); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs index e1cafe5057..96f8c73743 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs @@ -4,8 +4,10 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Shouldly; +using Unity.TenantManagement.Application; using Unity.TenantManagement.Application.Contracts; using Volo.Abp; +using Volo.Abp.TenantManagement; using Xunit; namespace Unity.TenantManagement; @@ -21,6 +23,8 @@ protected override void AfterAddApplication(IServiceCollection services) tenantConnectionStringBuilder.GenerateCredentialsAsync() .Returns(Task.FromResult(new TenantDbCredentials("T_ABC123", "T_ABC123", "XYZ789"))); + tenantConnectionStringBuilder.GenerateReadOnlyCredentials(Arg.Any()) + .Returns(new TenantDbCredentials("T_ABC123", "T_ABC123_readonly", "RO12345")); tenantConnectionStringBuilder.Build(Arg.Any(), Arg.Any()) .Returns("acme test connection"); @@ -78,6 +82,11 @@ public async Task CreateAsync() var tenant = await _tenantAppService.CreateAsync(new TenantCreateDto { Name = tenancyName }); tenant.Name.ShouldBe(tenancyName); tenant.Id.ShouldNotBe(Guid.Empty); + + var tenantRepository = GetRequiredService(); + var tenantInDb = await tenantRepository.GetAsync(tenant.Id, includeDetails: true); + tenantInDb.ConnectionStrings.ShouldContain(cs => cs.Name == UnityTenantManagementConsts.TenantConnectionStringName); + tenantInDb.ConnectionStrings.ShouldContain(cs => cs.Name == UnityTenantManagementConsts.TenantReadOnlyConnectionStringName); } [Fact] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs index e9e3e3803c..2fa79c7c18 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs @@ -1,5 +1,6 @@ using Unity.Flex.Localization; using Unity.GrantManager.Localization; +using Unity.Modules.Shared.Specializations; using Unity.Notifications.Localization; using Unity.Payments.Localization; using Volo.Abp.Features; @@ -68,6 +69,14 @@ public override void Define(IFeatureDefinitionContext context) displayName: LocalizableString .Create("Analytics"), valueType: new ToggleStringValueType()); + + var specializationGroup = context.AddGroup(SpecializationConsts.GroupName); + + specializationGroup.AddFeature(SpecializationConsts.Onboarding, + defaultValue: defaultValue, + displayName: LocalizableString + .Create("Onboarding"), + valueType: new ToggleStringValueType()); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/UserSearchDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/UserSearchDto.cs index 0ea0841997..87207a3838 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/UserSearchDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/UserSearchDto.cs @@ -12,5 +12,7 @@ public class UserSearchDto public string LastName { get; set; } = string.Empty; public string UserGuid { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Css/ICssUsersApiService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Css/ICssUsersApiService.cs index d08adfe688..f1fdb24c05 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Css/ICssUsersApiService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Css/ICssUsersApiService.cs @@ -6,6 +6,6 @@ namespace Unity.GrantManager.Integrations.Css public interface ICssUsersApiService : IApplicationService { Task FindUserAsync(string directory, string guid); - Task SearchUsersAsync(string directory, string? firstName = null, string? lastName = null); + Task SearchUsersAsync(string directory, string? firstName = null, string? lastName = null, string? email = null); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index 528a13cc91..abb0a430cc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -257,10 +257,10 @@ private async Task UpdateApplicationFormVersionFields(ApplicationFormVersion app public async Task GetFormVersionByApplicationIdAsync(Guid applicationId) { var formSubmission = await formSubmissionRepository.GetByApplicationAsync(applicationId); - - if (formSubmission.FormVersionId == null) + + if (formSubmission == null || formSubmission.FormVersionId == null) { - return await HandleEmptyFormVersionIdAsync(formSubmission); + return await HandleEmptyFormVersionIdAsync(formSubmission!); } return await GetVersion(formSubmission.FormVersionId ?? Guid.Empty); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs index a210176c75..f6425a24f2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Unity.GrantManager.Data; using Unity.GrantManager.Identity; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus; +using Volo.Abp.FeatureManagement; using Volo.Abp.MultiTenancy; using Volo.Abp.TenantManagement; @@ -15,18 +18,20 @@ public class TenantCreatedEventHandler private readonly ITenantRepository _tenantRepository; private readonly ICurrentTenant _currentTenant; private readonly IUserImportAppService _userImportAppService; - + private readonly IFeatureAppService _featureAppService; private readonly GrantManagerDbMigrationService _grantManagerDbMigrationService; public TenantCreatedEventHandler(ITenantRepository tenantRepository, ICurrentTenant currentTenant, IUserImportAppService userImportAppService, + IFeatureAppService featureAppService, GrantManagerDbMigrationService grantManagerDbMigrationService) { _tenantRepository = tenantRepository; _grantManagerDbMigrationService = grantManagerDbMigrationService; _currentTenant = currentTenant; _userImportAppService = userImportAppService; + _featureAppService = featureAppService; } public async Task HandleEventAsync(TenantCreatedEto tenantCreatedEto) @@ -42,6 +47,27 @@ await _grantManagerDbMigrationService await _userImportAppService.ImportUserAsync(new ImportUserDto() { Directory = "IDIR", Guid = userIdentifier, Roles = new string[] { UnityRoles.ProgramManager } }); } + + await EnableRequestedFeaturesAsync(tenantCreatedEto, tenant.Id); + } + + private async Task EnableRequestedFeaturesAsync(TenantCreatedEto eto, Guid tenantId) + { + if (!eto.Properties.TryGetValue("FeatureKeys", out var featureKeysRaw) || + string.IsNullOrWhiteSpace(featureKeysRaw)) + return; + + var featureUpdates = featureKeysRaw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(key => new UpdateFeatureDto { Name = key, Value = "true" }) + .ToList(); + + if (featureUpdates.Count == 0) return; + + await _featureAppService.UpdateAsync( + "T", // TenantFeatureValueProvider.ProviderName + tenantId.ToString(), + new UpdateFeaturesDto { Features = featureUpdates }); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/CssOnboardingUserLookup.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/CssOnboardingUserLookup.cs new file mode 100644 index 0000000000..37aaed4647 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/CssOnboardingUserLookup.cs @@ -0,0 +1,24 @@ +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Integrations.Css; +using Unity.TenantManagement; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Identity; + +[RemoteService(false)] +[ExposeServices(typeof(IOnboardingUserLookup))] +public class CssOnboardingUserLookup(ICssUsersApiService cssUsersApiService) + : IOnboardingUserLookup, ITransientDependency +{ + public async Task FindUserGuidByEmailAsync(string email) + { + var result = await cssUsersApiService.SearchUsersAsync("idir", email: email); + if (!result.Success || result.Data == null || result.Data.Length == 0) + return null; + + var guid = result.Data[0].Attributes?.IdirUserGuid?.FirstOrDefault(); + return string.IsNullOrWhiteSpace(guid) ? null : guid; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs index 01547def96..9c320fd76d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs @@ -125,7 +125,9 @@ public async Task> SearchAsync(UserSearchDto importUserSearchDto) new ValidationResult("Only idir search is supported") }); // for now - if (string.IsNullOrEmpty(importUserSearchDto.FirstName) && string.IsNullOrEmpty(importUserSearchDto.LastName)) + if (string.IsNullOrEmpty(importUserSearchDto.FirstName) && + string.IsNullOrEmpty(importUserSearchDto.LastName) && + string.IsNullOrEmpty(importUserSearchDto.Email)) return users; if (!string.IsNullOrEmpty(importUserSearchDto.FirstName) && importUserSearchDto.FirstName.Length < 2) @@ -146,7 +148,16 @@ public async Task> SearchAsync(UserSearchDto importUserSearchDto) }); } - var result = await _cssUsersApiService.SearchUsersAsync(importUserSearchDto.Directory, importUserSearchDto.FirstName, importUserSearchDto.LastName); + if (!string.IsNullOrEmpty(importUserSearchDto.Email) && importUserSearchDto.Email.Length < 2) + { + throw new AbpValidationException("Validation Error", + new List() + { + new ValidationResult("Email length must be greater than 2") + }); + } + + var result = await _cssUsersApiService.SearchUsersAsync(importUserSearchDto.Directory, importUserSearchDto.FirstName, importUserSearchDto.LastName, importUserSearchDto.Email); if (!result.Success) throw new UserFriendlyException("Error searching directory users"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs index c734129591..1c54e1c59f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs @@ -35,7 +35,7 @@ public async Task FindUserAsync(string directory, string guid) return await SearchSsoAsync(directory, parameters); } - public async Task SearchUsersAsync(string directory, string? firstName = null, string? lastName = null) + public async Task SearchUsersAsync(string directory, string? firstName = null, string? lastName = null, string? email = null) { var parameters = new Dictionary(); @@ -45,6 +45,9 @@ public async Task SearchUsersAsync(string directory, string? f if (!string.IsNullOrWhiteSpace(lastName) && lastName.Length >= 2) parameters.Add(nameof(lastName), lastName); + if (!string.IsNullOrWhiteSpace(email) && email.Length >= 2) + parameters.Add(nameof(email), email); + return await SearchSsoAsync(directory, parameters); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json index 626191b07d..2d97676b33 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json @@ -1,7 +1,11 @@ { "ConnectionStrings": { "Default": "Host=localhost;port=5432;Database=UnityGrantManager;Username=postgres;", - "Tenant": "Host=localhost;port=5432;Database=UnityGrantTenant;Username=postgres;" + "Tenant": "Host=localhost;port=5432;Database=UnityGrantTenant;Username=postgres;", + "Onboarding": "Host=localhost;port=5432;Database=Onboarding;Username=postgres;" + }, + "StringEncryption": { + "DefaultPassPhrase": "g2IuZx7PwXDvCmlW" }, "Redis": { "Configuration": "127.0.0.1" diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 35d17e82b6..aa4183d5fa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -15,6 +15,7 @@ "Menu:Intakes": "Intakes", "Menu:ApplicationForms": "Forms", "Menu:TenantManagement": "Tenants", + "Menu:Onboarding": "Onboarding", "Menu:EndpointManagement": "Endpoints", "Menu:Applicants": "Applicants", "Menu:ConfigurationManagement": "Configuration Management", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationFormSubmissionRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationFormSubmissionRepository.cs index 6dfc2f04e2..bbd335b401 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationFormSubmissionRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationFormSubmissionRepository.cs @@ -6,6 +6,6 @@ namespace Unity.GrantManager.Applications; public interface IApplicationFormSubmissionRepository : IRepository { - Task GetByApplicationAsync(Guid applicationId); + Task GetByApplicationAsync(Guid applicationId); Task GetFormDetailsByApplicationIdAsync(Guid applicationId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerConsts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerConsts.cs index 21788549cf..8a55c0ca67 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerConsts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerConsts.cs @@ -15,6 +15,12 @@ public static class GrantManagerConsts public const string DefaultTenantConnectionStringName = "Tenant"; + public const string DefaultTenantReadOnlyConnectionStringName = "Tenant_Readonly"; + + public const string OnboardingTenantName = "Onboarding"; + public static string NormalizedOnboardingTenantName => OnboardingTenantName.ToUpper(); + public const string OnboardingTenantConnectionStringConfigKey = "Onboarding"; + public const string DefaultConnectionStringName = "Default"; public const string UnknownValue = "Unknown"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDefaultTenantSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDefaultTenantSeederContributor.cs index d7721894b1..c817bed8ca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDefaultTenantSeederContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDefaultTenantSeederContributor.cs @@ -1,7 +1,9 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using System.Threading.Tasks; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; +using Volo.Abp.FeatureManagement; +using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; namespace Unity.GrantManager @@ -11,14 +13,20 @@ public class GrantManagerDefaultTenantSeederContributor : IDataSeedContributor, private readonly ITenantManager _tenantManager; private readonly ITenantRepository _tenantRepository; private readonly IConfiguration _configuration; + private readonly IFeatureManager _featureManager; + private readonly IStringEncryptionService _encryptionService; public GrantManagerDefaultTenantSeederContributor(ITenantManager tenantManager, ITenantRepository tenantRepository, - IConfiguration configuration) + IConfiguration configuration, + IFeatureManager featureManager, + IStringEncryptionService encryptionService) { _tenantManager = tenantManager; _tenantRepository = tenantRepository; _configuration = configuration; + _featureManager = featureManager; + _encryptionService = encryptionService; } public async Task SeedAsync(DataSeedContext context) @@ -27,33 +35,59 @@ public async Task SeedAsync(DataSeedContext context) if (context.TenantId == null) { - // Read the configuration and populate any default tenants - var tenant = await _tenantRepository.FindByNameAsync(GrantManagerConsts.NormalizedDefaultTenantName); + await SeedDefaultTenantAsync(); + await SeedOnboardingTenantAsync(); + } + } - // Legacy check for pre v8 upgrade - we dont want migrations to fail because of this breaking changed - if (tenant == null) + private async Task SeedDefaultTenantAsync() + { + var tenant = await _tenantRepository.FindByNameAsync(GrantManagerConsts.NormalizedDefaultTenantName); + + // Legacy check for pre v8 upgrade - we dont want migrations to fail because of this breaking changed + if (tenant == null) + { + var list = await _tenantRepository.GetListAsync(); + tenant = list.Find(s => s.Name == GrantManagerConsts.DefaultTenantName); + if (tenant != null) { - var list = await _tenantRepository.GetListAsync(); - tenant = list.Find(s => s.Name == GrantManagerConsts.DefaultTenantName); - if (tenant != null) - { - await _tenantManager.ChangeNameAsync(tenant, tenant.Name); - } + await _tenantManager.ChangeNameAsync(tenant, tenant.Name); } + } - if (tenant == null) + if (tenant == null) + { + var tenantConnectionString = _configuration.GetConnectionString(GrantManagerConsts.DefaultTenantConnectionStringName); + if (tenantConnectionString != null) { - var tenantConnectionString = _configuration.GetConnectionString(GrantManagerConsts.DefaultTenantConnectionStringName); - if (tenantConnectionString != null) - { - var newTenant = await _tenantManager.CreateAsync(GrantManagerConsts.DefaultTenantName); - newTenant.ConnectionStrings.Add(new TenantConnectionString(newTenant.Id, GrantManagerConsts.DefaultTenantConnectionStringName, tenantConnectionString)); - await _tenantRepository.InsertAsync(newTenant, true); - } + var newTenant = await _tenantManager.CreateAsync(GrantManagerConsts.DefaultTenantName); + newTenant.ConnectionStrings.Add(new TenantConnectionString(newTenant.Id, GrantManagerConsts.DefaultTenantConnectionStringName, _encryptionService.Encrypt(tenantConnectionString))); + await _tenantRepository.InsertAsync(newTenant, true); } } } - } -} + private async Task SeedOnboardingTenantAsync() + { + var tenant = await _tenantRepository.FindByNameAsync(GrantManagerConsts.NormalizedOnboardingTenantName); + if (tenant == null) + { + var connectionString = _configuration.GetConnectionString(GrantManagerConsts.OnboardingTenantConnectionStringConfigKey); + if (connectionString != null) + { + var newTenant = await _tenantManager.CreateAsync(GrantManagerConsts.OnboardingTenantName); + newTenant.ConnectionStrings.Add(new TenantConnectionString(newTenant.Id, GrantManagerConsts.DefaultTenantConnectionStringName, _encryptionService.Encrypt(connectionString))); + await _tenantRepository.InsertAsync(newTenant, true); + + // "Unity.Onboarding" = SpecializationConsts.Onboarding; "T" = TenantFeatureValueProvider.ProviderName + await _featureManager.SetAsync( + "Unity.Onboarding", + "true", + "T", + newTenant.Id.ToString()); + } + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs index b7bba5eeb2..009d2c001b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs @@ -37,31 +37,15 @@ public async Task MigrateAsync(Tenant? tenant) if (tenant != null) { - var connectionString = tenant.ConnectionStrings[0]; + var connectionString = tenant.FindConnectionString(GrantManagerConsts.DefaultTenantConnectionStringName); if (connectionString != null) { var configuration = _serviceProvider.GetRequiredService(); var adminConnectionString = configuration.GetConnectionString(GrantManagerConsts.DefaultConnectionStringName) ?? throw new InvalidOperationException($"Connection string '{GrantManagerConsts.DefaultConnectionStringName}' is not configured."); - // Decrypt the stored value — plain-text rows (pre-encryption) fall back to their original value. - // A successful decrypt that produces non-connection-string output (wrong passphrase returning garbage) - // is also rejected by the Contains('=') check so we surface a meaningful error rather than a - // cryptic NpgsqlConnectionStringBuilder format exception. - var rawValue = connectionString.Value; - string plainValue; - try - { - var decrypted = _encryptionService.Decrypt(rawValue); - plainValue = (decrypted != null && decrypted.Contains('=')) ? decrypted : rawValue; - } - catch - { - plainValue = rawValue; - } - // Parse the stored tenant connection string to get the DB name and role credentials - var tenantCsb = new NpgsqlConnectionStringBuilder(plainValue); + var tenantCsb = new NpgsqlConnectionStringBuilder(TryDecryptConnectionString(connectionString)); var dbName = tenantCsb.Database ?? throw new InvalidOperationException("Tenant connection string is missing the Database value."); var roleName = tenantCsb.Username @@ -103,6 +87,20 @@ await tenantDb.ExecuteSqlRawAsync( // Grant table and sequence privileges after migrations have created all objects await GrantTablePrivilegesAsync(adminTenantConnectionString, roleName); + // Provision the readonly role/connection (if a readonly connection string was generated for this tenant) + var readOnlyConnectionString = tenant.FindConnectionString(GrantManagerConsts.DefaultTenantReadOnlyConnectionStringName); + if (readOnlyConnectionString != null) + { + var readOnlyCsb = new NpgsqlConnectionStringBuilder(TryDecryptConnectionString(readOnlyConnectionString)); + var readOnlyRoleName = readOnlyCsb.Username + ?? throw new InvalidOperationException("Tenant readonly connection string is missing the Username value."); + var readOnlyPassword = readOnlyCsb.Password + ?? throw new InvalidOperationException("Tenant readonly connection string is missing the Password value."); + + await CreateRoleIfNotExistsAsync(adminConnectionString, readOnlyRoleName, readOnlyPassword); + await GrantReadOnlyPrivilegesAsync(adminConnectionString, adminTenantConnectionString, dbName, readOnlyRoleName); + } + /* The payments module is also migrated. Currently the payments module also references the tenant connection string. Changes to that may require inspecting the connection string here to resolve @@ -118,6 +116,23 @@ await _serviceProvider } } + // Decrypt the stored value — plain-text rows (pre-encryption) fall back to their original value. + // A successful decrypt that produces non-connection-string output (wrong passphrase returning garbage) + // is also rejected by the Contains('=') check so we surface a meaningful error rather than a + // cryptic NpgsqlConnectionStringBuilder format exception. + private string TryDecryptConnectionString(string rawValue) + { + try + { + var decrypted = _encryptionService.Decrypt(rawValue); + return (decrypted != null && decrypted.Contains('=')) ? decrypted : rawValue; + } + catch + { + return rawValue; + } + } + private static async Task CreateRoleIfNotExistsAsync(string adminConnectionString, string roleName, string password) { await using var conn = new NpgsqlConnection(adminConnectionString); @@ -182,4 +197,44 @@ AND nspname NOT LIKE 'pg_%' """; await cmd.ExecuteNonQueryAsync(); } + + private static async Task GrantReadOnlyPrivilegesAsync(string adminConnectionString, string adminTenantConnectionString, string dbName, string roleName) + { + await using (var conn = new NpgsqlConnection(adminConnectionString)) + { + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"GRANT CONNECT ON DATABASE \"{dbName}\" TO \"{roleName}\""; + await cmd.ExecuteNonQueryAsync(); + } + + await using (var conn = new NpgsqlConnection(adminTenantConnectionString)) + { + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + + // Grant read-only access (USAGE/SELECT) on every non-system schema, including + // tables and sequences created by future migrations via ALTER DEFAULT PRIVILEGES. + cmd.CommandText = $""" + DO $$ + DECLARE + r RECORD; + BEGIN + FOR r IN + SELECT nspname FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND nspname NOT LIKE 'pg_%' + LOOP + EXECUTE format('GRANT USAGE ON SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('GRANT SELECT ON ALL SEQUENCES IN SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT SELECT ON TABLES TO "{roleName}"', r.nspname); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT SELECT ON SEQUENCES TO "{roleName}"', r.nspname); + END LOOP; + END + $$; + """; + await cmd.ExecuteNonQueryAsync(); + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationFormSubmissionRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationFormSubmissionRepository.cs index 34dcbe6ed8..35c589cb81 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationFormSubmissionRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationFormSubmissionRepository.cs @@ -18,11 +18,11 @@ public ApplicationFormSubmissionRepository(IDbContextProvider GetByApplicationAsync(Guid applicationId) + public async Task GetByApplicationAsync(Guid applicationId) { var dbContext = await GetDbContextAsync(); return await dbContext.ApplicationFormSubmissions - .FirstAsync(s => s.ApplicationId == applicationId); + .FirstOrDefaultAsync(s => s.ApplicationId == applicationId); } public async Task GetFormDetailsByApplicationIdAsync(Guid applicationId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 10e9446c70..777d5f6066 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -173,7 +173,11 @@ internal static void Register(ServiceConfigurationContext context) authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Delete)); authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageFeatures, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageFeatures)); + policy => policy.RequireAssertion(context => + context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageFeatures) || + context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || + context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) + )); authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageConnectionStrings)); @@ -201,6 +205,30 @@ internal static void Register(ServiceConfigurationContext context) context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) )); + // Tenant management combined: Tenants.Default OR ITOperations + authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsOrITOps, + policy => policy.RequireAssertion(context => + context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Default) || + context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || + context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) + )); + + // Tenant management combined: Tenants.Update OR ITOperations + authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsUpdateOrITOps, + policy => policy.RequireAssertion(context => + context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Update) || + context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || + context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) + )); + + // Tenant management combined: Tenants.Create OR ITOperations + authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsCreateOrITOps, + policy => policy.RequireAssertion(context => + context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Create) || + context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || + context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) + )); + // Project Info Policies authorizationBuilder.AddPolicy(UnitySelector.Project.Default, policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Default)); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs index 620938fe68..0e559fbe83 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs @@ -2,6 +2,8 @@ using Unity.GrantManager.Localization; using Unity.GrantManager.Permissions; using Unity.Identity.Web.Navigation; +using Unity.Modules.Shared.Navigation; +using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; using Unity.TenantManagement; using Unity.TenantManagement.Web.Navigation; @@ -27,7 +29,18 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex { var l = context.GetLocalizer(); - context.Menu.AddItem( + await context.AddItemAsync( + new ApplicationMenuItem( + TenantManagementMenuNames.Onboarding, + l["Menu:Onboarding"], + "~/TenantManagement/Onboarding", + icon: "fl fl-other-user", + order: 1, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + ).OnlyWhenSpecializations(SpecializationConsts.Onboarding) + ); + + await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.Applications, l["Menu:Applications"], @@ -35,10 +48,10 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex icon: "fl fl-other-user", order: 1, requiredPermissionName: GrantManagerPermissions.Default - ) + ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) ); - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.Applicants, l["Menu:Applicants"], @@ -46,21 +59,21 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex icon: "fl fl-other-user", order: 2, requiredPermissionName: GrantApplicationPermissions.Applicants.ViewList + ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + ); + + await context.AddItemAsync( + new ApplicationMenuItem( + UnityIdentityMenuNames.Roles, + l["Menu:Roles"], + "~/Identity/Roles", + icon: "fl fl-settings", + order: 3, + requiredPermissionName: IdentityPermissions.Roles.Default ) ); - context.Menu.AddItem( - new ApplicationMenuItem( - UnityIdentityMenuNames.Roles, - l["Menu:Roles"], - "~/Identity/Roles", - icon: "fl fl-settings", - order: 3, - requiredPermissionName: IdentityPermissions.Roles.Default - ) - ); - - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( UnityIdentityMenuNames.Users, l["Menu:Users"], @@ -71,7 +84,7 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex ) ); - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.Intakes, l["Menu:Intakes"], @@ -82,7 +95,7 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex ) ); - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.ApplicationForms, l["Menu:ApplicationForms"], @@ -93,7 +106,7 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex ) ); - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.Dashboard, l["Menu:Dashboard"], @@ -101,41 +114,54 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex icon: "fl fl-view-dashboard", order: 7, requiredPermissionName: GrantApplicationPermissions.Dashboard.Default - ) + ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) ); + // Displayed in the Grant Manager - Used at Tenant Level if the user in the IT Operations role - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.EndpointManagement, displayName: "Endpoints", "~/EndpointManagement/Endpoints", requiredPermissionName: IdentityConsts.ITOperationsPermissionName - ) + ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) ); // ******************** - // Admin - Tenant Management - context.Menu.AddItem( - new ApplicationMenuItem( - TenantManagementMenuNames.Tenants, - l["Menu:TenantManagement"], - "~/TenantManagement/Tenants", - icon: "fl fl-view-dashboard", - order: 8, - requiredPermissionName: TenantManagementPermissions.Tenants.Default - ) + // Admin - Tenant Management + await context.AddItemAsync( + new ApplicationMenuItem( + TenantManagementMenuNames.Tenants, + l["Menu:TenantManagement"], + "~/TenantManagement/Tenants", + icon: "fl fl-view-dashboard", + order: 8, + requiredPermissionName: TenantManagementPermissions.Tenants.Default + ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + ); + + // Tenants list for ITOperations users on the Onboarding tenant + await context.AddItemAsync( + new ApplicationMenuItem( + TenantManagementMenuNames.Tenants, + l["Menu:TenantManagement"], + "~/TenantManagement/Tenants", + icon: "fl fl-view-dashboard", + order: 8, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + ).OnlyWhenSpecializations(SpecializationConsts.Onboarding) ); - // Displayed on the Tenant Managment area if the user has the ITAdministrator Role - context.Menu.AddItem( + // Displayed on the Tenant Management area if the user has the ITAdministrator Role + await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.EndpointManagement, displayName: "Endpoints", "~/EndpointManagement/Endpoints", requiredPermissionName: TenantManagementPermissions.Tenants.Default - ) + ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) ); - + // End Admin ******************** #pragma warning disable S125 // Sections of code should not be commented out /* - will complete later after fixing ui sub menu issue */ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantPrograms/Index.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantPrograms/Index.cshtml.cs index 7e062d57dc..03f99656b3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantPrograms/Index.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantPrograms/Index.cshtml.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Threading.Tasks; +using Unity.Modules.Shared.Specializations; +using Volo.Abp.Features; using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Web.Pages.GrantPrograms @@ -16,12 +18,15 @@ public class IndexModel : GrantManagerPageModel private readonly ICurrentPrincipalAccessor _currentPrincipalAccessor; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IFeatureChecker _featureChecker; public IndexModel(ICurrentPrincipalAccessor currentPrincipalAccessor, - IHttpContextAccessor httpContextAccessor) + IHttpContextAccessor httpContextAccessor, + IFeatureChecker featureChecker) { _currentPrincipalAccessor = currentPrincipalAccessor; _httpContextAccessor = httpContextAccessor; + _featureChecker = featureChecker; } public void OnGet() @@ -31,20 +36,33 @@ public void OnGet() public async Task OnPostAsync() { - // Try update claims principal on the fly? - seems to cause some issues, so far only reliable way to do this is signout and go through the auth process again - if (_httpContextAccessor != null && _httpContextAccessor.HttpContext != null) + // Determine landing page for the target tenant before signing out + var redirectUrl = "/GrantApplications"; + if (SwapTenantId.HasValue) + { + using (CurrentTenant.Change(SwapTenantId)) + { + if (await _featureChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) + { + redirectUrl = "/TenantManagement/Onboarding"; + } + } + } + + // Signout and re-auth is the only reliable way to swap tenant claims + if (_httpContextAccessor?.HttpContext != null) { await _httpContextAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await _httpContextAccessor.HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); } - if (_currentPrincipalAccessor != null && _currentPrincipalAccessor.Principal != null) + if (_currentPrincipalAccessor?.Principal != null) { Response.Cookies.Append("set_tenant", SwapTenantId?.ToString() ?? Guid.Empty.ToString(), new CookieOptions() - { Secure = true, SameSite = SameSiteMode.None, HttpOnly = true }); - } + { Secure = true, SameSite = SameSiteMode.None, HttpOnly = true }); + } - return Redirect("/GrantApplications"); + return Redirect(redirectUrl); } } } From 147c143d0518fb01f863adf9cffa97a22528e2c2 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 18 Jun 2026 14:58:33 -0700 Subject: [PATCH 04/51] AB#33309 further enhancements for filtering and validations --- .../IWorksheetInstanceAppService.cs | 11 +- .../WorksheetInstanceDataDto.cs | 13 + .../IWorksheetInstanceRepository.cs | 5 + .../WorksheetInstanceRepository.cs | 47 ++ .../Worksheets/WorksheetInstanceAppService.cs | 57 +- .../ColumnFilterDto.cs | 7 + .../IOnboardingApplicationProvider.cs | 24 + .../IOnboardingRequestAppService.cs | 11 +- .../OnboardingApplicationRecord.cs | 13 + .../OnboardingColumnConfigDto.cs | 33 ++ .../OnboardingListRequestDto.cs | 12 + .../OnboardingRequestDto.cs | 6 +- ...ngColumnConfigSettingDefinitionProvider.cs | 23 + .../OnboardingColumnConfigSettings.cs | 11 + .../Onboarding/OnboardingFeatureMap.cs | 43 +- .../OnboardingRequestAppService.cs | 540 +++++++++++++----- .../Unity.TenantManagement.Application.csproj | 2 + .../UnityTenantManagementApplicationModule.cs | 4 +- .../Validation/SuperUsersValidationStep.cs | 2 +- .../OnboardingRequestController.cs | 36 +- .../Onboarding/CreateTenantModal.cshtml | 89 ++- .../TenantManagement/Onboarding/Index.cshtml | 19 +- .../TenantManagement/Onboarding/Index.js | 536 ++++++++++++++--- .../Pages/TenantManagement/Tenants/Index.js | 15 +- .../client-proxies/multi-tenancy-proxy.js | 58 +- .../GrantApplicationAppService.cs | 211 +++---- .../Identity/UserImportAppService.cs | 10 +- ...antManagerOnboardingApplicationProvider.cs | 188 ++++++ .../Localization/GrantManager/en.json | 44 +- .../OnboardingApplicationManager.cs | 91 +++ .../GrantManagerWebModule.cs | 1 + .../OnboardingRedirectMiddleware.cs | 33 ++ .../Pages/GrantApplications/Details.cshtml.cs | 6 + .../ApplicationActionWidget/Default.js | 2 + 34 files changed, 1840 insertions(+), 363 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/WorksheetInstanceDataDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ColumnFilterDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingListRequestDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/OnboardingRedirectMiddleware.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs index c19a67de98..0463a2d8ff 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs @@ -1,5 +1,7 @@ -using System.Threading.Tasks; -using System; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; namespace Unity.Flex.WorksheetInstances @@ -9,5 +11,10 @@ public interface IWorksheetInstanceAppService : IApplicationService Task GetByCorrelationAnchorAsync(Guid correlationId, string correlationProvider, Guid worksheetId, string uiAnchor); Task CreateAsync(CreateWorksheetInstanceDto dto); Task UpdateAsync(PersistWorksheetIntanceValuesDto dto); + Task> GetListByCorrelationIdsAsync(List correlationIds, string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(List correlationIds, string correlationProvider); + Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount); + Task GetDataByIdAsync(Guid id); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/WorksheetInstanceDataDto.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/WorksheetInstanceDataDto.cs new file mode 100644 index 0000000000..4255e6135d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/WorksheetInstanceDataDto.cs @@ -0,0 +1,13 @@ +using System; + +namespace Unity.Flex.WorksheetInstances +{ + public class WorksheetInstanceDataDto + { + public Guid Id { get; set; } + public Guid CorrelationId { get; set; } + public Guid WorksheetId { get; set; } + public string CurrentValue { get; set; } = "{}"; + public DateTime CreationTime { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs index 4201df50ed..f6399f2f1a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs @@ -12,5 +12,10 @@ public interface IWorksheetInstanceRepository : IBasicRepository GetWithValuesAsync(Guid worksheetInstanceId); Task ExistsAsync(Guid worksheetId, Guid instanceCorrelationId, string instanceCorrelationProvider, Guid sheetCorrelationId, string sheetCorrelationProvider, string? uiAnchor); Task AnyByWorksheetAndFormVersionAsync(Guid worksheetId, Guid formVersionId); + Task> GetByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider); + Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount); + Task GetCountByCorrelationProviderAsync(string correlationProvider); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs index 71edcca8b6..60fdfb448f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs @@ -76,5 +76,52 @@ public async Task AnyByWorksheetAndFormVersionAsync(Guid worksheetId, Guid && s.WorksheetCorrelationId == formVersionId && s.WorksheetCorrelationProvider == CorrelationConsts.FormVersion); } + + public async Task> GetByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + var ids = correlationIds.ToList(); + return await dbSet + .Where(wi => ids.Contains(wi.CorrelationId) && wi.CorrelationProvider == correlationProvider) + .ToListAsync(); + } + + public async Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(wi => wi.CorrelationProvider == correlationProvider) + .Select(wi => wi.WorksheetId) + .Distinct() + .ToListAsync(); + } + + public async Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + var ids = correlationIds.ToList(); + return await dbSet + .Where(wi => ids.Contains(wi.CorrelationId) && wi.CorrelationProvider == correlationProvider) + .Select(wi => wi.WorksheetId) + .Distinct() + .ToListAsync(); + } + + public async Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(wi => wi.CorrelationProvider == correlationProvider) + .OrderByDescending(wi => wi.CreationTime) + .Skip(skipCount) + .Take(maxResultCount) + .ToListAsync(); + } + + public async Task GetCountByCorrelationProviderAsync(string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.CountAsync(wi => wi.CorrelationProvider == correlationProvider); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs index da2c963a19..8a5d534526 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs @@ -1,8 +1,11 @@ -using System.Threading.Tasks; -using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Unity.Flex.Domain.WorksheetInstances; using Unity.Flex.Domain.Services; +using Volo.Abp.Application.Dtos; namespace Unity.Flex.WorksheetInstances { @@ -29,5 +32,55 @@ public virtual async Task UpdateAsync(PersistWorksheetIntanceValuesDto dto) { await worksheetsManager.PersistWorksheetData(ObjectMapper.Map(dto)); } + + public virtual async Task> GetListByCorrelationIdsAsync(List correlationIds, string correlationProvider) + { + var instances = await worksheetInstanceRepository.GetByCorrelationIdsAsync(correlationIds, correlationProvider); + return instances.Select(wi => new WorksheetInstanceDataDto + { + CorrelationId = wi.CorrelationId, + WorksheetId = wi.WorksheetId, + CurrentValue = wi.CurrentValue + }).ToList(); + } + + public virtual async Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider) + { + return await worksheetInstanceRepository.GetDistinctWorksheetIdsByCorrelationProviderAsync(correlationProvider); + } + + public virtual async Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(List correlationIds, string correlationProvider) + { + return await worksheetInstanceRepository.GetDistinctWorksheetIdsByCorrelationIdsAsync(correlationIds, correlationProvider); + } + + public virtual async Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount) + { + var totalCount = await worksheetInstanceRepository.GetCountByCorrelationProviderAsync(correlationProvider); + var instances = await worksheetInstanceRepository.GetPagedListByCorrelationProviderAsync(correlationProvider, skipCount, maxResultCount); + var items = instances.Select(wi => new WorksheetInstanceDataDto + { + Id = wi.Id, + CorrelationId = wi.CorrelationId, + WorksheetId = wi.WorksheetId, + CurrentValue = wi.CurrentValue, + CreationTime = wi.CreationTime + }).ToList(); + return new PagedResultDto(totalCount, items); + } + + public virtual async Task GetDataByIdAsync(Guid id) + { + var wi = await worksheetInstanceRepository.FindAsync(id); + if (wi == null) return null; + return new WorksheetInstanceDataDto + { + Id = wi.Id, + CorrelationId = wi.CorrelationId, + WorksheetId = wi.WorksheetId, + CurrentValue = wi.CurrentValue, + CreationTime = wi.CreationTime + }; + } } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ColumnFilterDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ColumnFilterDto.cs new file mode 100644 index 0000000000..95cd6b7bfa --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ColumnFilterDto.cs @@ -0,0 +1,7 @@ +namespace Unity.TenantManagement; + +public class ColumnFilterDto +{ + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs new file mode 100644 index 0000000000..dc2e7c1996 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs @@ -0,0 +1,24 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; + +namespace Unity.TenantManagement; + +public interface IOnboardingApplicationProvider +{ + Task> GetPagedListAsync( + int skipCount, + int maxResultCount, + string? sorting, + string category, + string? filter = null, + IReadOnlyList? globalDynamicMatchIds = null, + IReadOnlyList? staticColumnFilters = null, + IReadOnlyList? dynamicColumnMatchIds = null); + Task GetByIdAsync(Guid id); + Task> GetAllIdsAsync(string category); + Task> GetAvailableCategoriesAsync(); + Task CloseApplicationAsync(Guid applicationId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs index 5e5542070e..10cfd4acc0 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs @@ -1,5 +1,6 @@ -#nullable enable +#nullable enable using System; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -8,8 +9,10 @@ namespace Unity.TenantManagement; public interface IOnboardingRequestAppService : IApplicationService { - Task> GetListAsync(PagedAndSortedResultRequestDto input); + Task> GetListAsync(OnboardingListRequestDto input); Task GetAsync(Guid id); - Task ValidateAsync(Guid id); - Task CreateTenantAsync(Guid id); + Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null); + Task CreateTenantAsync(Guid id, CreateTenantInputDto? input); + Task GetColumnSchemaAsync(string? category = null); + Task> GetAvailableCategoriesAsync(); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs new file mode 100644 index 0000000000..c115cb6558 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs @@ -0,0 +1,13 @@ +#nullable enable +using System; + +namespace Unity.TenantManagement; + +public class OnboardingApplicationRecord +{ + public Guid Id { get; set; } + public string ReferenceNo { get; set; } = string.Empty; + public DateTime SubmissionDate { get; set; } + public string Status { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs new file mode 100644 index 0000000000..2a95c6a0af --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs @@ -0,0 +1,33 @@ +#nullable enable +using System.Collections.Generic; + +namespace Unity.TenantManagement; + +public class OnboardingColumnDto +{ + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public bool Selected { get; set; } +} + +public class OnboardingColumnSchemaDto +{ + public List Columns { get; set; } = []; + public string? TenantNameFieldKey { get; set; } + public string? SuperUsersFieldKey { get; set; } + public string? BranchFieldKey { get; set; } + public string? FeaturesFieldKey { get; set; } + public string? MinistryFieldKey { get; set; } + public string? ProgramAreaFieldKey { get; set; } +} + +public class CreateTenantInputDto +{ + public string? TenantNameFieldKey { get; set; } + public string? SuperUsersFieldKey { get; set; } + public string? BranchFieldKey { get; set; } + public string? FeaturesFieldKey { get; set; } + public string? MinistryFieldKey { get; set; } + public string? ProgramAreaFieldKey { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingListRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingListRequestDto.cs new file mode 100644 index 0000000000..8abeaea7d9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingListRequestDto.cs @@ -0,0 +1,12 @@ +#nullable enable +using System.Collections.Generic; +using Volo.Abp.Application.Dtos; + +namespace Unity.TenantManagement; + +public class OnboardingListRequestDto : PagedAndSortedResultRequestDto +{ + public string? Category { get; set; } = "Onboarding"; + public string? Filter { get; set; } + public List? ColumnFilters { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs index 1b3cb4c63a..c251999c11 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs @@ -1,10 +1,13 @@ -using System; +#nullable enable +using System; +using System.Collections.Generic; namespace Unity.TenantManagement; public class OnboardingRequestDto { public Guid Id { get; set; } + public string SubmissionNumber { get; set; } = string.Empty; public string TenantName { get; set; } = string.Empty; public string TenantDescription { get; set; } = string.Empty; public string ProgramAreaName { get; set; } = string.Empty; @@ -18,4 +21,5 @@ public class OnboardingRequestDto public string Status { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public DateTime? SubmissionDate { get; set; } + public Dictionary Fields { get; set; } = []; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs new file mode 100644 index 0000000000..c0000d45fc --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs @@ -0,0 +1,23 @@ +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; + +namespace Unity.TenantManagement.Onboarding; + +public class OnboardingColumnConfigSettingDefinitionProvider : SettingDefinitionProvider +{ + private static SettingDefinition OnboardingDef(string name) => + new SettingDefinition(name, defaultValue: null, isVisibleToClients: false, isInherited: false, isEncrypted: false) + .WithProviders(GlobalSettingValueProvider.ProviderName, UserSettingValueProvider.ProviderName); + + public override void Define(ISettingDefinitionContext context) + { + context.Add( + OnboardingDef(OnboardingColumnConfigSettings.TenantNameFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.SuperUsersFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.BranchFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.FeaturesFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.MinistryFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.ProgramAreaFieldKey) + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs new file mode 100644 index 0000000000..a2041e1c22 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs @@ -0,0 +1,11 @@ +namespace Unity.TenantManagement.Onboarding; + +public static class OnboardingColumnConfigSettings +{ + public const string TenantNameFieldKey = "Onboarding.ColumnConfig.TenantNameFieldKey"; + public const string SuperUsersFieldKey = "Onboarding.ColumnConfig.SuperUsersFieldKey"; + public const string BranchFieldKey = "Onboarding.ColumnConfig.BranchFieldKey"; + public const string FeaturesFieldKey = "Onboarding.ColumnConfig.FeaturesFieldKey"; + public const string MinistryFieldKey = "Onboarding.ColumnConfig.MinistryFieldKey"; + public const string ProgramAreaFieldKey = "Onboarding.ColumnConfig.ProgramAreaFieldKey"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs index 1063e60fc4..0bf2720292 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Unity.TenantManagement.Onboarding; @@ -8,6 +10,7 @@ internal static class OnboardingFeatureMap { private static readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase) { + // Display name / delimited-string variants ["Payments"] = "Unity.Payments", ["Notifications"] = "Unity.Notifications", ["Flex"] = "Unity.Flex", @@ -17,19 +20,51 @@ internal static class OnboardingFeatureMap ["AI Application Analysis"] = "Unity.AI.ApplicationAnalysis", ["AI Scoring"] = "Unity.AI.Scoring", ["Analytics"] = "Unity.Analytics", + // camelCase key aliases used by the checkbox-group JSON format + ["aiReporting"] = "Unity.AIReporting", + ["aiAttachmentSummaries"] = "Unity.AI.AttachmentSummaries", + ["aiApplicationAnalysis"] = "Unity.AI.ApplicationAnalysis", + ["aiScoring"] = "Unity.AI.Scoring", }; + private static readonly char[] _delimiters = [',', ';', '|']; + + private sealed class CheckboxItem + { + [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; + [JsonPropertyName("value")] public bool Value { get; set; } + } + /// - /// Parses a comma-separated features string and returns the feature keys for any recognised names. - /// Unrecognised names are silently skipped. + /// Accepts either a JSON checkbox-group array or a delimited string (comma / semicolon / pipe) + /// and returns the ABP feature keys for all enabled/listed recognised names. /// public static IReadOnlyList ResolveFeatureKeys(string features) { if (string.IsNullOrWhiteSpace(features)) return []; - return [.. features - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + IEnumerable tokens; + var trimmed = features.Trim(); + + if (trimmed.StartsWith('[')) + { + try + { + var items = JsonSerializer.Deserialize>(trimmed); + tokens = items?.Where(i => i.Value).Select(i => i.Key) ?? []; + } + catch + { + tokens = []; + } + } + else + { + tokens = trimmed.Split(_delimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + return [.. tokens .Select(token => _map.TryGetValue(token, out var key) ? key : null) .Where(key => key is not null) .Cast() diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs index d3ea3ba23a..c224bf7a34 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -1,24 +1,35 @@ -#nullable enable +#nullable enable using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Values; +using Unity.Flex.WorksheetInstances; using Unity.Modules.Shared.Permissions; using Unity.TenantManagement.Onboarding; using Unity.TenantManagement.Validation; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; +using Volo.Abp.SettingManagement; namespace Unity.TenantManagement; [Authorize(IdentityConsts.ITOperationsPolicyName)] public class OnboardingRequestAppService( - IEnumerable validationSteps + IEnumerable validationSteps, + ISettingManager settingManager ) : ApplicationService, IOnboardingRequestAppService { private readonly IEnumerable _validationSteps = validationSteps; + private readonly ISettingManager _settingManager = settingManager; + + private const string DefaultCategory = "Onboarding"; + private const string ApplicationCorrelationProvider = "Application"; + private const string UserProvider = "U"; private ITenantAppService TenantAppService => LazyServiceProvider.LazyGetRequiredService(); @@ -26,112 +37,201 @@ IEnumerable validationSteps private IOnboardingUserLookup? UserLookup => LazyServiceProvider.LazyGetService(); - private static readonly List MockOnboardingRequests = - [ - new() - { - Id = Guid.NewGuid(), - TenantName = "Community Housing Programs", - TenantDescription = "Grant programs supporting community housing initiatives", - ProgramAreaName = "Housing Supply", - ProgramAreaDescription = "Programs that increase the supply of affordable housing", - Contacts = "Jane Smith, Tom Lee", - Features = "Application Intake, Reporting", - SuperUsers = "jane.smith@gov.bc.ca", - ExecutiveDirector = "Pat Wilson", - Branch = "Housing Policy Branch", - Ministry = "Ministry of Housing", - Status = "In Review", - Category = "Grant", - SubmissionDate = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc) - }, - new() + private IOnboardingApplicationProvider? ApplicationProvider => + LazyServiceProvider.LazyGetService(); + + private IWorksheetAppService WorksheetAppService => + LazyServiceProvider.LazyGetRequiredService(); + + private IWorksheetInstanceAppService WorksheetInstanceAppService => + LazyServiceProvider.LazyGetRequiredService(); + + public virtual async Task> GetListAsync(OnboardingListRequestDto input) + { + if (ApplicationProvider == null) + return new PagedResultDto(0, []); + + var category = string.IsNullOrWhiteSpace(input.Category) ? DefaultCategory : input.Category; + + bool hasGlobalFilter = !string.IsNullOrWhiteSpace(input.Filter); + var staticColumnFilters = GetStaticColumnFilters(input.ColumnFilters); + var dynamicColumnFilters = GetDynamicColumnFilters(input.ColumnFilters); + var (sortField, sortDescending) = ParseSorting(input.Sorting); + bool isDynamicSort = sortField != null && !IsStaticColumn(sortField); + + IReadOnlyList? globalDynamicMatchIds = null; + IReadOnlyList? dynamicColumnMatchIds = null; + Dictionary>? allInstancesByApp = null; + + if (hasGlobalFilter || dynamicColumnFilters.Count > 0 || isDynamicSort) { - Id = Guid.NewGuid(), - TenantName = "Clean Energy Rebates", - TenantDescription = "Rebate programs for clean energy adoption", - ProgramAreaName = "Energy Efficiency", - ProgramAreaDescription = "Programs that promote energy efficient retrofits", - Contacts = "Alex Chen", - Features = "Application Intake, Payments, Reporting", - SuperUsers = "andre.goncalves@gov.bc.ca", - ExecutiveDirector = "Morgan Lee", - Branch = "Energy Programs Branch", - Ministry = "Ministry of Energy and Climate Solutions", - Status = "Approved", - Category = "Rebate", - SubmissionDate = new DateTime(2025, 11, 3, 0, 0, 0, DateTimeKind.Utc) - }, - new() + var allIds = await ApplicationProvider.GetAllIdsAsync(category); + + if (allIds.Count > 0) + { + var allInstances = await WorksheetInstanceAppService + .GetListByCorrelationIdsAsync(allIds, ApplicationCorrelationProvider); + + allInstancesByApp = allInstances + .GroupBy(wi => wi.CorrelationId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + + if (hasGlobalFilter) + { + globalDynamicMatchIds = allIds + .Where(id => MatchesGlobalInWorksheet( + allInstancesByApp.GetValueOrDefault(id, []), input.Filter!)) + .ToList(); + } + + if (dynamicColumnFilters.Count > 0) + { + dynamicColumnMatchIds = allIds + .Where(id => dynamicColumnFilters.All(cf => + MatchesColumnInWorksheet( + allInstancesByApp.GetValueOrDefault(id, []), cf.Name, cf.Value))) + .ToList(); + } + } + else if (dynamicColumnFilters.Count > 0) + { + dynamicColumnMatchIds = []; // No apps → nothing matches dynamic filters + } + } + + PagedResultDto appPage; + + if (isDynamicSort) { - Id = Guid.NewGuid(), - TenantName = "Agricultural Innovation Fund", - TenantDescription = "Funding to support innovation in the agriculture sector", - ProgramAreaName = "Agri-Innovation", - ProgramAreaDescription = "Programs that fund innovative agricultural projects", - Contacts = "Priya Patel, Sam O'Brien", - Features = "Application Intake, Scoring, Reporting", - SuperUsers = "priya.patel@gov.bc.ca", - ExecutiveDirector = "Casey Brown", - Branch = "Industry Competitiveness Branch", - Ministry = "Ministry of Agriculture and Food", - Status = "Pending", - Category = "Grant", - SubmissionDate = new DateTime(2026, 3, 22, 0, 0, 0, DateTimeKind.Utc) - }, - new() + // Worksheet field values live in JSONB, not a queryable column, so the DB can't + // sort by them — fetch every filtered match and sort/page in memory instead. + var filtered = await ApplicationProvider.GetPagedListAsync( + 0, int.MaxValue, null, category, + hasGlobalFilter ? input.Filter : null, + globalDynamicMatchIds, + staticColumnFilters.Count > 0 ? staticColumnFilters : null, + dynamicColumnMatchIds); + + IOrderedEnumerable ordered = sortDescending + ? filtered.Items.OrderByDescending( + a => GetWorksheetFieldValue(allInstancesByApp?.GetValueOrDefault(a.Id, []) ?? [], sortField!), + StringComparer.OrdinalIgnoreCase) + : filtered.Items.OrderBy( + a => GetWorksheetFieldValue(allInstancesByApp?.GetValueOrDefault(a.Id, []) ?? [], sortField!), + StringComparer.OrdinalIgnoreCase); + + var pagedItems = ordered.Skip(input.SkipCount).Take(input.MaxResultCount).ToList(); + appPage = new PagedResultDto(filtered.TotalCount, pagedItems); + } + else { - Id = Guid.NewGuid(), - TenantName = "Small Business Recovery Grants", - TenantDescription = "Grants supporting small business recovery and growth", - ProgramAreaName = "Business Supports", - ProgramAreaDescription = "Programs that provide direct supports to small businesses", - Contacts = "Jordan Taylor", - Features = "Application Intake, Payments", - SuperUsers = "jordan.taylor@gov.bc.ca", - ExecutiveDirector = "Riley Anderson", - Branch = "Small Business Branch", - Ministry = "Ministry of Jobs, Economic Development and Innovation", - Status = "In Review", - Category = "Grant", - SubmissionDate = new DateTime(2026, 2, 8, 0, 0, 0, DateTimeKind.Utc) - }, - new() + appPage = await ApplicationProvider.GetPagedListAsync( + input.SkipCount, input.MaxResultCount, input.Sorting, category, + hasGlobalFilter ? input.Filter : null, + globalDynamicMatchIds, + staticColumnFilters.Count > 0 ? staticColumnFilters : null, + dynamicColumnMatchIds); + } + + if (appPage.TotalCount == 0) + return new PagedResultDto(0, []); + + var applicationIds = appPage.Items.Select(a => a.Id).ToList(); + var worksheetInstances = await WorksheetInstanceAppService + .GetListByCorrelationIdsAsync(applicationIds, ApplicationCorrelationProvider); + + var instancesByApp = worksheetInstances + .GroupBy(wi => wi.CorrelationId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var mapping = await ReadTenantMappingAsync(); + var items = appPage.Items + .Select(app => MapToDto( + app, + instancesByApp.GetValueOrDefault(app.Id, []), + mapping)) + .ToList(); + + return new PagedResultDto(appPage.TotalCount, items); + } + + public virtual async Task GetAsync(Guid id) + { + if (ApplicationProvider == null) return null; + + var app = await ApplicationProvider.GetByIdAsync(id); + if (app == null) return null; + + var worksheetInstances = await WorksheetInstanceAppService + .GetListByCorrelationIdsAsync([id], ApplicationCorrelationProvider); + + var mapping = await ReadTenantMappingAsync(); + return MapToDto(app, worksheetInstances, mapping); + } + + public virtual async Task GetColumnSchemaAsync(string? category = null) + { + if (ApplicationProvider == null) + return new OnboardingColumnSchemaDto { Columns = [] }; + + var resolvedCategory = string.IsNullOrWhiteSpace(category) ? DefaultCategory : category; + var onboardingAppIds = await ApplicationProvider.GetAllIdsAsync(resolvedCategory); + + if (onboardingAppIds.Count == 0) + return new OnboardingColumnSchemaDto { Columns = [] }; + + var worksheetIds = await WorksheetInstanceAppService + .GetDistinctWorksheetIdsByCorrelationIdsAsync(onboardingAppIds, ApplicationCorrelationProvider); + + var seenKeys = new HashSet(); + var columns = new List(); + + foreach (var worksheetId in worksheetIds) { - Id = Guid.NewGuid(), - TenantName = "Watershed Restoration Grants", - TenantDescription = "Grants funding the restoration of watershed ecosystems", - ProgramAreaName = "Environmental Stewardship", - ProgramAreaDescription = "Programs that support environmental restoration projects", - Contacts = "Taylor Morgan, Casey Nguyen", - Features = "Application Intake, Reporting", - SuperUsers = "taylor.morgan@gov.bc.ca", - ExecutiveDirector = "Drew Campbell", - Branch = "Ecosystems Branch", - Ministry = "Ministry of Water, Land and Resource Stewardship", - Status = "Approved", - Category = "Contribution Agreement", - SubmissionDate = new DateTime(2025, 9, 17, 0, 0, 0, DateTimeKind.Utc) + WorksheetDto worksheet; + try { worksheet = await WorksheetAppService.GetAsync(worksheetId); } + catch { continue; } + + foreach (var field in worksheet.Sections + .OrderBy(s => s.Order) + .SelectMany(s => s.Fields.OrderBy(f => f.Order)) + .Where(f => f.Enabled)) + { + if (!seenKeys.Add($"{field.Key}|{field.Label}")) continue; + columns.Add(new OnboardingColumnDto + { + Key = field.Key, + Label = field.Label, + Type = field.Type.ToString(), + Selected = true + }); + } } - ]; - public virtual Task GetAsync(Guid id) + var saved = await ReadTenantMappingAsync(); + saved.Columns = columns; + return saved; + } + + public virtual async Task> GetAvailableCategoriesAsync() { - return Task.FromResult(MockOnboardingRequests.FirstOrDefault(r => r.Id == id)); + if (ApplicationProvider == null) return [DefaultCategory]; + var categories = await ApplicationProvider.GetAvailableCategoriesAsync(); + // Always ensure "Onboarding" is present even if no forms carry it yet + if (!categories.Contains(DefaultCategory)) + categories.Insert(0, DefaultCategory); + return categories; } - public virtual async Task ValidateAsync(Guid id) + public virtual async Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null) { - var request = MockOnboardingRequests.FirstOrDefault(r => r.Id == id); + var request = await GetAsync(id); if (request == null) - return new OnboardingValidationResultDto - { - IsValid = false, - Issues = ["Onboarding request not found."] - }; + return new OnboardingValidationResultDto { IsValid = false, Issues = ["Onboarding request not found."] }; - var issues = new List(); + await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + var issues = new List(); foreach (var step in _validationSteps.OrderBy(s => s.Order)) { var stepResult = await step.ValidateAsync(request); @@ -139,21 +239,21 @@ public virtual async Task ValidateAsync(Guid id) issues.Add($"[{step.StepName}] {stepResult.Issue}"); } - return new OnboardingValidationResultDto - { - IsValid = issues.Count == 0, - Issues = issues - }; + return new OnboardingValidationResultDto { IsValid = issues.Count == 0, Issues = issues }; } - public virtual async Task CreateTenantAsync(Guid id) + public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input) { - var request = MockOnboardingRequests.FirstOrDefault(r => r.Id == id) + var request = await GetAsync(id) ?? throw new UserFriendlyException("Onboarding request not found."); + await ResolveFieldMappings(request, input?.TenantNameFieldKey, input?.SuperUsersFieldKey, input?.BranchFieldKey, input?.FeaturesFieldKey, input?.MinistryFieldKey, input?.ProgramAreaFieldKey); + + if (input != null) + await SaveFieldMappingAsync(input.TenantNameFieldKey, input.SuperUsersFieldKey, input.BranchFieldKey, input.FeaturesFieldKey, input.MinistryFieldKey, input.ProgramAreaFieldKey); + var emails = SuperUsersValidationStep.ParseEmails(request.SuperUsers); - // Resolve all valid GUIDs in host context before tenant creation var userGuids = new List(); if (UserLookup is not null) { @@ -170,7 +270,6 @@ public virtual async Task CreateTenantAsync(Guid id) var featureKeys = OnboardingFeatureMap.ResolveFeatureKeys(request.Features); - // TenantCreatedEto handler migrates/seeds the DB, imports the first user, and enables features var tenantDto = await TenantAppService.CreateAsync(new TenantCreateDto { Name = request.TenantName, @@ -180,7 +279,6 @@ public virtual async Task CreateTenantAsync(Guid id) FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null }); - // Assign any additional super users as program managers foreach (var userGuid in userGuids.Skip(1)) { await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto @@ -189,48 +287,226 @@ await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto UserIdentifier = userGuid }); } + + if (ApplicationProvider != null) + await ApplicationProvider.CloseApplicationAsync(id); + } + + private async Task ResolveFieldMappings(OnboardingRequestDto request, + string? tenantNameKey = null, string? superUsersKey = null, + string? branchKey = null, string? featuresKey = null, + string? ministryKey = null, string? programAreaKey = null) + { + var saved = await ReadTenantMappingAsync(); + tenantNameKey ??= saved.TenantNameFieldKey; + superUsersKey ??= saved.SuperUsersFieldKey; + branchKey ??= saved.BranchFieldKey; + featuresKey ??= saved.FeaturesFieldKey; + ministryKey ??= saved.MinistryFieldKey; + programAreaKey ??= saved.ProgramAreaFieldKey; + + if (!string.IsNullOrEmpty(tenantNameKey) && request.Fields.TryGetValue(tenantNameKey, out var tenantNameVal) && tenantNameVal is not null) + request.TenantName = tenantNameVal.ToString()!; + if (!string.IsNullOrEmpty(superUsersKey) && request.Fields.TryGetValue(superUsersKey, out var superUsersVal) && superUsersVal is not null) + request.SuperUsers = superUsersVal.ToString()!; + if (!string.IsNullOrEmpty(branchKey) && request.Fields.TryGetValue(branchKey, out var branchVal) && branchVal is not null) + request.Branch = branchVal.ToString()!; + if (!string.IsNullOrEmpty(featuresKey) && request.Fields.TryGetValue(featuresKey, out var featuresVal) && featuresVal is not null) + request.Features = featuresVal.ToString()!; + if (!string.IsNullOrEmpty(ministryKey) && request.Fields.TryGetValue(ministryKey, out var ministryVal) && ministryVal is not null) + request.Ministry = ministryVal.ToString()!; + if (!string.IsNullOrEmpty(programAreaKey) && request.Fields.TryGetValue(programAreaKey, out var programAreaVal) && programAreaVal is not null) + request.ProgramAreaName = programAreaVal.ToString()!; } - public virtual Task> GetListAsync(PagedAndSortedResultRequestDto input) + private async Task SaveFieldMappingAsync(string? tenantNameKey, string? superUsersKey, string? branchKey, string? featuresKey, string? ministryKey, string? programAreaKey) { - var sorted = ApplySorting(MockOnboardingRequests, input.Sorting ?? string.Empty); + var userId = CurrentUser.Id?.ToString(); + if (string.IsNullOrEmpty(userId)) return; + await _settingManager.SetAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, tenantNameKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, superUsersKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.BranchFieldKey, branchKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, featuresKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.MinistryFieldKey, ministryKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, programAreaKey, UserProvider, userId); + } + + private async Task ReadTenantMappingAsync() + { + var userId = CurrentUser.Id?.ToString(); + string? tenantNameKey = null, superUsersKey = null, branchKey = null, featuresKey = null, ministryKey = null, programAreaKey = null; + + if (!string.IsNullOrEmpty(userId)) + { + tenantNameKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, UserProvider, userId); + superUsersKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, UserProvider, userId); + branchKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.BranchFieldKey, UserProvider, userId); + featuresKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, UserProvider, userId); + ministryKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.MinistryFieldKey, UserProvider, userId); + programAreaKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, UserProvider, userId); + } + + tenantNameKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.TenantNameFieldKey); + superUsersKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey); + branchKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.BranchFieldKey); + featuresKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.FeaturesFieldKey); + ministryKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.MinistryFieldKey); + programAreaKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey); - var items = sorted - .Skip(input.SkipCount) - .Take(input.MaxResultCount) + return new OnboardingColumnSchemaDto + { + TenantNameFieldKey = tenantNameKey, + SuperUsersFieldKey = superUsersKey, + BranchFieldKey = branchKey, + FeaturesFieldKey = featuresKey, + MinistryFieldKey = ministryKey, + ProgramAreaFieldKey = programAreaKey + }; + } + + private static List GetStaticColumnFilters(List? filters) => + (filters ?? []) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && IsStaticColumn(cf.Name)) .ToList(); - return Task.FromResult(new PagedResultDto(MockOnboardingRequests.Count, items)); + private static List GetDynamicColumnFilters(List? filters) => + (filters ?? []) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && !IsStaticColumn(cf.Name)) + .ToList(); + + private static bool IsStaticColumn(string name) => + name is "submissionNumber" or "status" or "category" or "submissionDate"; + + private static (string? Field, bool Descending) ParseSorting(string? sorting) + { + if (string.IsNullOrWhiteSpace(sorting)) return (null, false); + var parts = sorting.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var descending = parts.Length > 1 && parts[1].Equals("DESC", StringComparison.OrdinalIgnoreCase); + + // Dynamic columns use a "fields." dot-path on the client so DataTables can + // resolve the cell value; strip that prefix to get back the raw worksheet field key. + var field = parts[0]; + if (field.StartsWith("fields.", StringComparison.OrdinalIgnoreCase)) + field = field["fields.".Length..]; + + return (field, descending); } - private static IEnumerable ApplySorting(IEnumerable query, string sorting) + private static string? GetWorksheetFieldValue( + IReadOnlyList instances, string fieldKey) { - if (sorting.IsNullOrWhiteSpace()) + foreach (var wi in instances) { - return query.OrderBy(o => o.TenantName); + if (string.IsNullOrWhiteSpace(wi.CurrentValue) || wi.CurrentValue == "{}") continue; + try + { + var parsed = JsonSerializer.Deserialize(wi.CurrentValue); + var match = parsed?.Values?.FirstOrDefault(fv => + fv.Key.Equals(fieldKey, StringComparison.OrdinalIgnoreCase)); + if (match != null) return match.Value; + } + catch + { + // Malformed JSONB — skip silently + } } + return null; + } - var sortParts = sorting.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); - var sortField = sortParts[0]; - var descending = sortParts.Length > 1 && sortParts[1].Equals("DESC", StringComparison.OrdinalIgnoreCase); + private static bool MatchesGlobalInWorksheet( + IReadOnlyList instances, string filter) + { + var lowerFilter = filter.ToLowerInvariant(); + return instances.Any(wi => + { + if (string.IsNullOrWhiteSpace(wi.CurrentValue) || wi.CurrentValue == "{}") return false; + try + { + var parsed = JsonSerializer.Deserialize(wi.CurrentValue); + return parsed?.Values?.Any(fv => + fv.Value?.ToLowerInvariant().Contains(lowerFilter) ?? false) ?? false; + } + catch { return false; } + }); + } - Func keySelector = sortField switch + private static bool MatchesColumnInWorksheet( + IReadOnlyList instances, string fieldKey, string value) + { + var lowerValue = value.ToLowerInvariant(); + return instances.Any(wi => { - nameof(OnboardingRequestDto.TenantDescription) => o => o.TenantDescription, - nameof(OnboardingRequestDto.ProgramAreaName) => o => o.ProgramAreaName, - nameof(OnboardingRequestDto.ProgramAreaDescription) => o => o.ProgramAreaDescription, - nameof(OnboardingRequestDto.Contacts) => o => o.Contacts, - nameof(OnboardingRequestDto.Features) => o => o.Features, - nameof(OnboardingRequestDto.SuperUsers) => o => o.SuperUsers, - nameof(OnboardingRequestDto.ExecutiveDirector) => o => o.ExecutiveDirector, - nameof(OnboardingRequestDto.Branch) => o => o.Branch, - nameof(OnboardingRequestDto.Ministry) => o => o.Ministry, - nameof(OnboardingRequestDto.Status) => o => o.Status, - nameof(OnboardingRequestDto.Category) => o => o.Category, - nameof(OnboardingRequestDto.SubmissionDate) => o => o.SubmissionDate?.ToString("O") ?? "", - _ => o => o.TenantName + if (string.IsNullOrWhiteSpace(wi.CurrentValue) || wi.CurrentValue == "{}") return false; + try + { + var parsed = JsonSerializer.Deserialize(wi.CurrentValue); + return parsed?.Values?.Any(fv => + fv.Key.Equals(fieldKey, StringComparison.OrdinalIgnoreCase) && + (fv.Value?.ToLowerInvariant().Contains(lowerValue) ?? false)) ?? false; + } + catch { return false; } + }); + } + + private static OnboardingRequestDto MapToDto( + OnboardingApplicationRecord app, + IEnumerable worksheetInstances, + OnboardingColumnSchemaDto mapping) + { + var dto = new OnboardingRequestDto + { + Id = app.Id, + SubmissionNumber = app.ReferenceNo, + SubmissionDate = app.SubmissionDate, + Status = app.Status, + Category = app.Category }; - return descending ? query.OrderByDescending(keySelector) : query.OrderBy(keySelector); + foreach (var instance in worksheetInstances) + { + if (string.IsNullOrWhiteSpace(instance.CurrentValue) || instance.CurrentValue == "{}") continue; + + try + { + var parsed = JsonSerializer.Deserialize(instance.CurrentValue); + if (parsed?.Values == null) continue; + + foreach (var fv in parsed.Values) + { + dto.Fields[fv.Key] = fv.Value; + + switch (fv.Key.ToLowerInvariant().Replace("-", "").Replace("_", "").Replace(" ", "")) + { + case "tenantdescription": case "description": dto.TenantDescription = fv.Value; break; + case "programareaname": case "programarea": dto.ProgramAreaName = fv.Value; break; + case "programareadescription": dto.ProgramAreaDescription = fv.Value; break; + case "contacts": dto.Contacts = fv.Value; break; + case "features": dto.Features = fv.Value; break; + case "executivedirector": dto.ExecutiveDirector = fv.Value; break; + case "branch": dto.Branch = fv.Value; break; + case "ministry": dto.Ministry = fv.Value; break; + } + } + } + catch + { + // Malformed JSONB — skip silently + } + } + + if (!string.IsNullOrEmpty(mapping.TenantNameFieldKey) && dto.Fields.TryGetValue(mapping.TenantNameFieldKey, out var tn) && tn != null) + dto.TenantName = tn.ToString()!; + if (!string.IsNullOrEmpty(mapping.SuperUsersFieldKey) && dto.Fields.TryGetValue(mapping.SuperUsersFieldKey, out var su) && su != null) + dto.SuperUsers = su.ToString()!; + if (!string.IsNullOrEmpty(mapping.BranchFieldKey) && dto.Fields.TryGetValue(mapping.BranchFieldKey, out var br) && br != null) + dto.Branch = br.ToString()!; + if (!string.IsNullOrEmpty(mapping.FeaturesFieldKey) && dto.Fields.TryGetValue(mapping.FeaturesFieldKey, out var ft) && ft != null) + dto.Features = ft.ToString()!; + if (!string.IsNullOrEmpty(mapping.MinistryFieldKey) && dto.Fields.TryGetValue(mapping.MinistryFieldKey, out var mn) && mn != null) + dto.Ministry = mn.ToString()!; + if (!string.IsNullOrEmpty(mapping.ProgramAreaFieldKey) && dto.Fields.TryGetValue(mapping.ProgramAreaFieldKey, out var pa) && pa != null) + dto.ProgramAreaName = pa.ToString()!; + + return dto; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj index 8746e24419..37aa38e406 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj @@ -19,10 +19,12 @@ + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs index 98cc1dbb0c..5c4344e95a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Unity.Flex; using Volo.Abp.Mapperly; using Volo.Abp.Modularity; using Volo.Abp.TenantManagement; @@ -9,7 +10,8 @@ namespace Unity.TenantManagement typeof(AbpTenantManagementDomainModule), typeof(UnityTenantManagementApplicationContractsModule), typeof(AbpTenantManagementApplicationModule), - typeof(AbpMapperlyModule) + typeof(AbpMapperlyModule), + typeof(FlexApplicationContractsModule) )] public class UnityTenantManagementApplicationModule : AbpModule { diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs index f20da4857f..2c0f8d29f4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs @@ -32,5 +32,5 @@ public async Task ValidateAsync(OnboardingReques } internal static string[] ParseEmails(string superUsers) => - [.. superUsers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(e => e.Contains('@'))]; + [.. superUsers.Split([',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(e => e.Contains('@'))]; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs index 857bbda694..e00ada72a8 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs @@ -1,5 +1,6 @@ -#nullable enable +#nullable enable using System; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp; @@ -11,14 +12,14 @@ namespace Unity.TenantManagement; [Controller] [RemoteService(Name = TenantManagementRemoteServiceConsts.RemoteServiceName)] [Area(TenantManagementRemoteServiceConsts.ModuleName)] -[Route("api/multi-tenancy/onboarding-requests")] +[Route("api/onboarding-requests")] public class OnboardingRequestController(IOnboardingRequestAppService onboardingRequestAppService) : AbpControllerBase, IOnboardingRequestAppService { protected IOnboardingRequestAppService OnboardingRequestAppService { get; } = onboardingRequestAppService; [HttpGet] - public virtual Task> GetListAsync(PagedAndSortedResultRequestDto input) + public virtual Task> GetListAsync([FromQuery] OnboardingListRequestDto input) { if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetListAsync: ModelState Invalid"); return OnboardingRequestAppService.GetListAsync(input); @@ -32,16 +33,37 @@ public virtual Task> GetListAsync(PagedAndS } [HttpGet("{id}/validate")] - public virtual Task ValidateAsync(Guid id) + public virtual Task ValidateAsync( + Guid id, + [FromQuery] string? tenantNameFieldKey = null, + [FromQuery] string? superUsersFieldKey = null, + [FromQuery] string? branchFieldKey = null, + [FromQuery] string? featuresFieldKey = null, + [FromQuery] string? ministryFieldKey = null, + [FromQuery] string? programAreaFieldKey = null) { if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->ValidateAsync: ModelState Invalid"); - return OnboardingRequestAppService.ValidateAsync(id); + return OnboardingRequestAppService.ValidateAsync(id, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); } [HttpPost("{id}/create-tenant")] - public virtual Task CreateTenantAsync(Guid id) + public virtual Task CreateTenantAsync(Guid id, [FromBody] CreateTenantInputDto? input = null) { if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->CreateTenantAsync: ModelState Invalid"); - return OnboardingRequestAppService.CreateTenantAsync(id); + return OnboardingRequestAppService.CreateTenantAsync(id, input); + } + + [HttpGet("column-schema")] + public virtual Task GetColumnSchemaAsync([FromQuery] string? category = null) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetColumnSchemaAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetColumnSchemaAsync(category); + } + + [HttpGet("categories")] + public virtual Task> GetAvailableCategoriesAsync() + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetAvailableCategoriesAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetAvailableCategoriesAsync(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml index 653907fc98..28e7295ef1 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml @@ -1,54 +1,103 @@ @page +@using Microsoft.AspNetCore.Mvc.Localization +@using Unity.GrantManager.Localization @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal @using Unity.TenantManagement @using Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding @model CreateTenantModalModel +@inject IHtmlLocalizer L @{ Layout = null; } - + -
    -
    Ministry
    -
    @Model.OnboardingRequest.Ministry
    -
    Branch
    -
    @Model.OnboardingRequest.Branch
    -
    Program Area
    -
    @Model.OnboardingRequest.ProgramAreaName
    -
    Category
    -
    @Model.OnboardingRequest.Category
    -
    Features
    -
    @Model.OnboardingRequest.Features
    -
    + -
    +
    - Validating request... + @L["CreateTenantModal:Validating"]
    - Creating tenant, please wait... + @L["CreateTenantModal:CreatingSpinner"]
    - This may take a moment. - Tenant provisioning is in progress — please do not close this window or navigate away until the process completes. + @L["CreateTenantModal:CreatingWarningTitle"] + @L["CreateTenantModal:CreatingWarningBody"]
    diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml index db62e196e9..853763404a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml @@ -1,13 +1,16 @@ @page +@using Microsoft.AspNetCore.Mvc.Localization +@using Unity.GrantManager.Localization @using Unity.TenantManagement.Web.Navigation @using Volo.Abp.AspNetCore.Mvc.UI.Layout @using Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding @model IndexModel +@inject IHtmlLocalizer L @inject IPageLayout PageLayout @{ - PageLayout.Content.BreadCrumb.Add("Onboarding"); + PageLayout.Content.BreadCrumb.Add(L["Menu:Onboarding"].Value); PageLayout.Content.MenuItemName = TenantManagementMenuNames.Onboarding; - ViewBag.PageTitle = "Onboarding"; + ViewBag.PageTitle = L["Menu:Onboarding"].Value; } @section styles { @@ -24,16 +27,20 @@
    - + +
    +
    + +
    + class="custom-table-btn flex-none btn btn-secondary action-bar-btn-unavailable">@L["OnboardingList:OpenButton"] - + class="custom-table-btn flex-none btn btn-secondary action-bar-btn-unavailable">@L["OnboardingList:CreateTenantButton"] +
    diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js index 31bbf32c94..64190e0278 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -1,7 +1,256 @@ (function () { + let l = abp.localization.getResource('GrantManager'); let _onboardingRequestAppService = unity.tenantManagement.onboardingRequest; let _dataTable = null; let _selectedRow = null; + let _selectedCategory = 'Onboarding'; + + // ─── Fixed columns (always present) ────────────────────────────────────── + + const FIXED_COLUMNS = [ + { title: l('Onboarding:ColumnSubmissionNumber'), data: 'submissionNumber', name: 'submissionNumber', index: 0 }, + { title: l('Onboarding:ColumnStatus'), data: 'status', name: 'status', index: 1 }, + { + title: l('Onboarding:ColumnSubmissionDate'), + data: 'submissionDate', + name: 'submissionDate', + index: 2, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + }, + { title: l('Onboarding:ColumnCategory'), data: 'category', name: 'category', index: 3 } + ]; + + // ─── Field value renderers ──────────────────────────────────────────────── + + function _escapeHtml(str) { + return String(str) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + } + + function _formatCheckboxKey(key) { + return String(key) + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, function (c) { return c.toUpperCase(); }); + } + + function _renderCheckboxGroup(data, type) { + if (data === null || data === undefined || data === '') return ''; + + // Worksheet CheckboxGroup values are always serialized as [{key, value}, ...] + let labels = []; + try { + const items = JSON.parse(data); + if (Array.isArray(items)) { + labels = items.filter(function (i) { return i?.value === true; }).map(function (i) { return i.key; }); + } + } catch { + labels = []; + } + + if (type === 'sort' || type === 'filter' || type === 'type') { + return labels.join(', '); + } + + if (labels.length === 0) return ''; + + return labels.map(function (label) { + return '' + + _escapeHtml(_formatCheckboxKey(label)) + + ''; + }).join(''); + } + + // ─── DataGrid cell renderer ─────────────────────────────────────────────── + + let _dataGridCache = {}; + let _dataGridCacheId = 0; + + function _renderDataGridIcon(data, columnTitle) { + if (!data) return ''; + + let parsed; + try { + parsed = typeof data === 'string' ? JSON.parse(data) : data; + } catch { + return ''; + } + if (!parsed || !Array.isArray(parsed.rows) || parsed.rows.length === 0) return ''; + + const cacheKey = 'dg-' + (_dataGridCacheId++); + _dataGridCache[cacheKey] = { grid: parsed, title: columnTitle }; + return ''; + } + + function _openDataGridModal(grid, title) { + const keys = []; + grid.rows.forEach(function (row) { + (row.cells || []).forEach(function (cell) { + if (keys.indexOf(cell.key) === -1) keys.push(cell.key); + }); + }); + + const headerHtml = keys.map(function (k) { return '' + _escapeHtml(_formatCheckboxKey(k)) + ''; }).join(''); + const rowsHtml = grid.rows.map(function (row) { + const cellsByKey = {}; + (row.cells || []).forEach(function (c) { cellsByKey[c.key] = c.value; }); + const tds = keys.map(function (k) { return '' + _escapeHtml(cellsByKey[k] ?? '') + ''; }).join(''); + return '' + tds + ''; + }).join(''); + + const modalHtml = ''; + + $('#onboardingDataGridModal').remove(); + $('body').append(modalHtml); + const modalEl = document.getElementById('onboardingDataGridModal'); + const modal = new bootstrap.Modal(modalEl); + modalEl.addEventListener('hidden.bs.modal', function () { $(modalEl).remove(); }); + modal.show(); + } + + $(document).on('click', '.onboarding-datagrid-btn', function (e) { + e.stopPropagation(); + const cached = _dataGridCache[$(this).data('datagrid-key')]; + if (cached) _openDataGridModal(cached.grid, cached.title); + }); + + // ─── Column builder ─────────────────────────────────────────────────────── + + function buildColumns(schema) { + let cols = FIXED_COLUMNS.map(function (c, i) { return { ...c, index: i }; }); + + if (schema?.columns) { + let offset = cols.length; + schema.columns.forEach(function (c, i) { + let col = { + title: c.label, + // Use a dot-path string (not a function) so DataTables/ABP can resolve + // the column's name for server-side sorting — a function data accessor + // breaks that resolution. + data: 'fields.' + c.key, + name: c.key, + defaultContent: '', + index: offset + i + }; + if (c.type === 'Date') { + col.render = function (data, type) { return DateUtils.formatUtcDateToLocal(data, type); }; + } else if (c.type === 'CheckboxGroup') { + col.render = _renderCheckboxGroup; + } else if (c.type === 'DataGrid') { + col.render = function (data, type) { return type === 'display' ? _renderDataGridIcon(data, c.label) : ''; }; + } + cols.push(col); + }); + } + + return cols; + } + + // ─── DataTable init ─────────────────────────────────────────────────────── + + function initTable(schema) { + if (_dataTable) { + _dataTable.off('select deselect'); + _dataTable.destroy(); + $('#OnboardingRequestsTable').empty(); + _dataTable = null; + _selectedRow = null; + manageActionButtons(); + } + + let listColumns = buildColumns(schema); + + _dataTable = initializeDataTable({ + dt: $('#OnboardingRequestsTable'), + listColumns: listColumns, + defaultSortColumn: 0, + dataEndpoint: _onboardingRequestAppService.getList, + data: function (requestData) { + const extras = { category: _selectedCategory }; + + const globalSearch = requestData?.search?.value; + if (globalSearch) extras.filter = globalSearch; + + // Column-level filters from FilterRow; category dropdown is always included + const columnFilters = (requestData?.columns || []) + .filter(function (col) { return col?.name && col?.search?.value; }) + .map(function (col) { return { name: col.name, value: col.search.value }; }); + + columnFilters.push({ name: 'category', value: _selectedCategory }); + extras.columnFilters = columnFilters; + + return extras; + }, + responseCallback: function (result) { + return { recordsTotal: result.totalCount, recordsFiltered: result.totalCount, data: result.items }; + }, + actionButtons: commonTableActionButtons(l('Menu:Onboarding')).filter(b => b.id !== 'btn-toggle-filter'), + serverSideEnabled: true, + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dynamicButtonContainerId: 'dynamicButtonContainerId', + externalSearchId: 'search', + lengthMenu: [10, 25, 50] + }); + + _dataTable.select.style('single'); + + _dataTable.on('select', function (e, dt, type, indexes) { + if (type === 'row' && indexes.length) { + _selectedRow = dt.row(indexes[0]).data(); + manageActionButtons(); + } + }); + + _dataTable.on('deselect', function (e, dt, type) { + if (type === 'row' && dt.rows({ selected: true }).count() === 0) { + _selectedRow = null; + manageActionButtons(); + } + }); + } + + // ─── Action button state ────────────────────────────────────────────────── + + function manageActionButtons() { + const hasSelection = _selectedRow !== null; + $('#btn-open-onboarding').toggleClass('action-bar-btn-unavailable', !hasSelection); + $('#btn-create-tenant').toggleClass('action-bar-btn-unavailable', + !hasSelection || _selectedRow.status !== 'Approved'); + } + + // ─── Field value preview ───────────────────────────────────────────────── + + let _fieldValues = {}; + + function _updateFieldPreview(selectId, previewId, renderer) { + const key = $('#' + selectId).val(); + const raw = key ? (_fieldValues[key] ?? '') : ''; + const text = raw ? String(raw) : ''; + const $preview = $('#' + previewId); + if (renderer) { + $preview.html(renderer(text, 'display') || ''); + } else { + $preview.text(text || '—').toggleClass('text-muted', !text); + } + } + + // ─── Create Tenant modal ────────────────────────────────────────────────── let _createTenantModal = new abp.ModalManager({ viewUrl: abp.appPath + 'TenantManagement/Onboarding/CreateTenantModal', @@ -12,7 +261,7 @@ $('#onboarding-validation-loading').addClass('d-none'); if (result.isValid) { $('#onboarding-validation-result') - .html('
    Validation passed. Ready to create tenant.
    ') + .html('
    ' + l('OnboardingModal:ValidationPassed') + '
    ') .removeClass('d-none'); $('#btn-confirm-create-tenant').prop('disabled', false); } else { @@ -20,7 +269,7 @@ .map(function (i) { return '
  • ' + $('').text(i).html() + '
  • '; }) .join(''); $('#onboarding-validation-result') - .html('
    Validation failed:
      ' + issuesHtml + '
    ') + .html('
    ' + l('OnboardingModal:ValidationFailed') + '
      ' + issuesHtml + '
    ') .removeClass('d-none'); } } @@ -28,124 +277,263 @@ function _renderValidationFail() { $('#onboarding-validation-loading').addClass('d-none'); $('#onboarding-validation-result') - .html('
    Failed to validate request. Please try again.
    ') + .html('
    ' + l('OnboardingModal:ValidateFailed') + '
    ') .removeClass('d-none'); } + function _triggerValidation(applicationId) { + $('#onboarding-validation-loading').removeClass('d-none'); + $('#onboarding-validation-result').addClass('d-none'); + $('#btn-confirm-create-tenant').prop('disabled', true); + + const tenantNameFieldKey = $('#create-tenant-tenant-name-field').val() || null; + const superUsersFieldKey = $('#create-tenant-super-users-field').val() || null; + const branchFieldKey = $('#create-tenant-branch-field').val() || null; + const featuresFieldKey = $('#create-tenant-features-field').val() || null; + const ministryFieldKey = $('#create-tenant-ministry-field').val() || null; + const programAreaFieldKey = $('#create-tenant-program-area-field').val() || null; + + const params = new URLSearchParams(); + if (tenantNameFieldKey) params.append('tenantNameFieldKey', tenantNameFieldKey); + if (superUsersFieldKey) params.append('superUsersFieldKey', superUsersFieldKey); + if (branchFieldKey) params.append('branchFieldKey', branchFieldKey); + if (featuresFieldKey) params.append('featuresFieldKey', featuresFieldKey); + if (ministryFieldKey) params.append('ministryFieldKey', ministryFieldKey); + if (programAreaFieldKey) params.append('programAreaFieldKey', programAreaFieldKey); + const query = params.size ? '?' + params.toString() : ''; + + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/' + applicationId + '/validate' + query, + type: 'GET' + }).done(_renderValidationResult).fail(_renderValidationFail); + } + function _onCreateTenantConfirm(applicationId) { return function () { - let $btn = $(this); + const $btn = $(this); $btn.prop('disabled', true); $('#btn-cancel-create-tenant').prop('disabled', true); + $('#onboarding-validation-result').addClass('d-none'); $('#onboarding-creating').removeClass('d-none'); + const tenantNameFieldKey = $('#create-tenant-tenant-name-field').val() || null; + const superUsersFieldKey = $('#create-tenant-super-users-field').val() || null; + const branchFieldKey = $('#create-tenant-branch-field').val() || null; + const featuresFieldKey = $('#create-tenant-features-field').val() || null; + const ministryFieldKey = $('#create-tenant-ministry-field').val() || null; + const programAreaFieldKey = $('#create-tenant-program-area-field').val() || null; + abp.ajax({ - url: abp.appPath + 'api/multi-tenancy/onboarding-requests/' + applicationId + '/create-tenant', - type: 'POST' + url: abp.appPath + 'api/onboarding-requests/' + applicationId + '/create-tenant', + type: 'POST', + data: JSON.stringify({ tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey }), + contentType: 'application/json' }).done(function () { - abp.notify.success('Tenant created successfully.'); + abp.notify.success(l('OnboardingModal:CreateSuccess')); _createTenantModal.close(); _dataTable.ajax.reloadEx(); }).fail(function () { $('#onboarding-creating').addClass('d-none'); $btn.prop('disabled', false); $('#btn-cancel-create-tenant').prop('disabled', false); - abp.notify.error('Failed to create tenant. Please try again.'); + abp.notify.error(l('OnboardingModal:CreateFailed')); }); }; } + function _wireCreateTenantMappingHandlers(applicationId) { + $('#create-tenant-ministry-field').on('change', function () { + _updateFieldPreview('create-tenant-ministry-field', 'create-tenant-ministry-value'); + }); + $('#create-tenant-branch-field').on('change', function () { + _updateFieldPreview('create-tenant-branch-field', 'create-tenant-branch-value'); + }); + $('#create-tenant-program-area-field').on('change', function () { + _updateFieldPreview('create-tenant-program-area-field', 'create-tenant-program-area-value'); + }); + $('#create-tenant-features-field').on('change', function () { + _updateFieldPreview('create-tenant-features-field', 'create-tenant-features-value', _renderCheckboxGroup); + }); + $('#create-tenant-tenant-name-field').on('change', function () { + _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); + _triggerValidation(applicationId); + }); + $('#create-tenant-super-users-field').on('change', function () { + _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value'); + _triggerValidation(applicationId); + }); + } + + function _renderNoFieldsWarning() { + $('#onboarding-validation-loading').addClass('d-none'); + $('#onboarding-validation-result') + .html('
    ' + l('CreateTenantModal:NoFieldsWarning') + '
    ') + .removeClass('d-none'); + $('#btn-confirm-create-tenant').prop('disabled', true); + } + + function _loadCreateTenantFields(applicationId) { + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/column-schema', + type: 'GET', + data: { category: _selectedCategory } + }).done(function (schema) { + if (!schema?.columns?.length) { + _renderNoFieldsWarning(); + return; + } + _renderMappingDropdown('create-tenant-ministry-field', schema.columns, MINISTRY_CANONICALS, schema.ministryFieldKey); + _renderMappingDropdown('create-tenant-branch-field', schema.columns, BRANCH_CANONICALS, schema.branchFieldKey); + _renderMappingDropdown('create-tenant-program-area-field', schema.columns, PROGRAM_AREA_CANONICALS, schema.programAreaFieldKey); + _renderMappingDropdown('create-tenant-features-field', schema.columns, FEATURES_CANONICALS, schema.featuresFieldKey); + _renderMappingDropdown('create-tenant-tenant-name-field', schema.columns, TENANT_NAME_CANONICALS, schema.tenantNameFieldKey); + _renderMappingDropdown('create-tenant-super-users-field', schema.columns, SUPER_USERS_CANONICALS, schema.superUsersFieldKey); + _updateFieldPreview('create-tenant-ministry-field', 'create-tenant-ministry-value'); + _updateFieldPreview('create-tenant-branch-field', 'create-tenant-branch-value'); + _updateFieldPreview('create-tenant-program-area-field', 'create-tenant-program-area-value'); + _updateFieldPreview('create-tenant-features-field', 'create-tenant-features-value', _renderCheckboxGroup); + _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); + _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value'); + $('#create-tenant-field-mapping').show(); + _wireCreateTenantMappingHandlers(applicationId); + _triggerValidation(applicationId); + }).fail(function () { + _renderValidationFail(); + }); + } + abp.modals.createTenantModal = function () { return { initModal: function (publicApi, args) { - let applicationId = args.id; + const applicationId = args.id; - abp.ajax({ - url: abp.appPath + 'api/multi-tenancy/onboarding-requests/' + applicationId + '/validate', - type: 'GET' - }).done(_renderValidationResult).fail(_renderValidationFail); + try { + const raw = document.getElementById('create-tenant-fields-json'); + _fieldValues = raw ? JSON.parse(raw.textContent) : {}; + } catch { _fieldValues = {}; } + _loadCreateTenantFields(applicationId); $('#btn-confirm-create-tenant').on('click', _onCreateTenantConfirm(applicationId)); } }; }; - let listColumns = [ - { title: 'Tenant Name', data: 'tenantName', name: 'tenantName', index: 0 }, - { title: 'Tenant Description', data: 'tenantDescription', name: 'tenantDescription', index: 1 }, - { title: 'Program Area Name', data: 'programAreaName', name: 'programAreaName', index: 2 }, - { title: 'Program Area Description', data: 'programAreaDescription', name: 'programAreaDescription', index: 3 }, - { title: 'Contact(s)', data: 'contacts', name: 'contacts', index: 4 }, - { title: 'Features', data: 'features', name: 'features', index: 5 }, - { title: 'Super Users', data: 'superUsers', name: 'superUsers', index: 6 }, - { title: 'Executive Director', data: 'executiveDirector', name: 'executiveDirector', index: 7 }, - { title: 'Branch', data: 'branch', name: 'branch', index: 8 }, - { title: 'Ministry', data: 'ministry', name: 'ministry', index: 9 }, - { title: 'Status', data: 'status', name: 'status', index: 10 }, - { title: 'Category', data: 'category', name: 'category', index: 11 }, - { - title: 'Submission Date', - data: 'submissionDate', - name: 'submissionDate', - index: 12, - render: function (data, type) { - return DateUtils.formatUtcDateToLocal(data, type); + // ─── Jaro-Winkler field auto-detection ─────────────────────────────────── + + function _normalizeLabel(s) { + return s + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .toLowerCase() + .trim(); + } + + function _computeJaroMatchings(s1, s2, matchDist) { + const l1 = s1.length, l2 = s2.length; + const s1m = new Array(l1).fill(false), s2m = new Array(l2).fill(false); + let matches = 0; + for (let i = 0; i < l1; i++) { + const lo = Math.max(0, i - matchDist), hi = Math.min(i + matchDist + 1, l2); + for (let j = lo; j < hi; j++) { + if (s2m[j] || s1[i] !== s2[j]) continue; + s1m[i] = s2m[j] = true; matches++; break; } } - ]; + return { s1m, s2m, matches }; + } - let responseCallback = function (result) { - return { - recordsTotal: result.totalCount, - recordsFiltered: result.totalCount, - data: result.items - }; - }; + function _computeJaroTranspositions(s1, s2, s1m, s2m) { + let k = 0, transpositions = 0; + for (let i = 0; i < s1.length; i++) { + if (!s1m[i]) continue; + while (!s2m[k]) k++; + if (s1[i] !== s2[k]) transpositions++; + k++; + } + return transpositions; + } - function manageActionButtons() { - const hasSelection = _selectedRow !== null; - $('#btn-open-onboarding').toggleClass('action-bar-btn-unavailable', !hasSelection); - $('#btn-create-tenant').toggleClass('action-bar-btn-unavailable', - !hasSelection || _selectedRow.status !== 'Approved'); + function _jaroWinkler(s1, s2) { + if (s1 === s2) return 1; + const l1 = s1.length, l2 = s2.length; + if (!l1 || !l2) return 0; + const matchDist = Math.max(Math.floor(Math.max(l1, l2) / 2) - 1, 0); + const { s1m, s2m, matches } = _computeJaroMatchings(s1, s2, matchDist); + if (!matches) return 0; + const transpositions = _computeJaroTranspositions(s1, s2, s1m, s2m); + const jaro = (matches / l1 + matches / l2 + (matches - transpositions / 2) / matches) / 3; + let prefix = 0; + for (let p = 0; p < Math.min(4, l1, l2); p++) { + if (s1[p] === s2[p]) prefix++; else break; + } + return jaro + prefix * 0.1 * (1 - jaro); } - $(function () { - _dataTable = initializeDataTable({ - dt: $('#OnboardingRequestsTable'), - listColumns: listColumns, - defaultSortColumn: 0, - dataEndpoint: _onboardingRequestAppService.getList, - data: { maxResultCount: 1000 }, - responseCallback: responseCallback, - actionButtons: commonTableActionButtons('Onboarding Requests').filter(b => b.id !== 'btn-toggle-filter'), - serverSideEnabled: false, - pagingEnabled: true, - reorderEnabled: true, - languageSetValues: {}, - dynamicButtonContainerId: 'dynamicButtonContainerId', - externalSearchId: 'search' + const TENANT_NAME_CANONICALS = ['tenant name', 'organization name', 'company name', 'program name', 'applicant name']; + const SUPER_USERS_CANONICALS = ['super user', 'super users', 'admin email', 'program manager', 'manager email', 'administrator', 'user email']; + const MINISTRY_CANONICALS = ['ministry', 'ministry name', 'government ministry', 'responsible ministry']; + const BRANCH_CANONICALS = ['branch', 'division branch', 'ministry branch', 'business branch']; + const PROGRAM_AREA_CANONICALS = ['program area', 'program area name', 'program name', 'program']; + const FEATURES_CANONICALS = ['features', 'feature flags', 'program features', 'modules', 'enabled features']; + const MATCH_THRESHOLD = 0.85; + + function _bestMatch(fields, canonicals) { + let best = null, bestScore = 0; + fields.forEach(function (f) { + const norm = _normalizeLabel(f.label || f.key); + const score = Math.max(...canonicals.map(function (c) { return _jaroWinkler(norm, c); })); + if (score > bestScore) { bestScore = score; best = f.key; } }); + return bestScore >= MATCH_THRESHOLD ? best : null; + } - _dataTable.select.style('single'); + function _renderMappingDropdown(selectId, fields, canonicals, savedKey) { + const $sel = $('#' + selectId); + $sel.find('option:not(:first)').remove(); + fields.forEach(function (f) { + $sel.append(''); + }); + const pick = savedKey || _bestMatch(fields, canonicals); + if (pick) $sel.val(pick); + } - _dataTable.on('select', function (e, dt, type, indexes) { - if (type === 'row' && indexes.length) { - _selectedRow = _dataTable.row(indexes[0]).data(); - manageActionButtons(); - } + // ─── Document ready ─────────────────────────────────────────────────────── + + function _loadSchemaAndInitTable() { + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/column-schema', + type: 'GET', + data: { category: _selectedCategory } + }).done(function (schema) { + initTable(schema); + }).fail(function () { + initTable(null); }); + } - _dataTable.on('deselect', function () { - if (_dataTable.rows({ selected: true }).count() === 0) { - _selectedRow = null; - manageActionButtons(); - } + $(function () { + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/categories', + type: 'GET' + }).done(function (categories) { + const $sel = $('#onboarding-category-filter'); + $sel.empty(); + (categories || ['Onboarding']).forEach(function (cat) { + $sel.append(''); + }); + $sel.val(_selectedCategory); + }).always(function () { + _loadSchemaAndInitTable(); + }); + + $('#onboarding-category-filter').on('change', function () { + _selectedCategory = $(this).val() || 'Onboarding'; + _loadSchemaAndInitTable(); }); $('#btn-open-onboarding').on('click', function () { if (!_selectedRow) return; - // TBD: open the onboarding request details - console.log('Open onboarding request:', _selectedRow); + globalThis.location.href = '/GrantApplications/Details?ApplicationId=' + _selectedRow.id; }); $('#btn-create-tenant').on('click', function () { diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js index ca643d9587..4e4e01096e 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js @@ -1,5 +1,6 @@ (function () { let l = abp.localization.getResource('AbpTenantManagement'); + let lGm = abp.localization.getResource('GrantManager'); let _tenantAppService = unity.tenantManagement.tenant; let _userImportService = unity.grantManager.identity.userImport; let _casClientCodeHash = {}; @@ -23,7 +24,7 @@ function _buildActionsCell(id, name) { let items = []; if (abp.auth.isGranted('UnityTenantManagement.Tenants.Update') || abp.auth.isGranted('ITOperations')) { - items.push('Configuration'); + items.push('' + lGm('TenantList:ConfigurationAction') + ''); } if (abp.auth.isGranted('UnityTenantManagement.Tenants.Delete')) { items.push('' + l('Delete') + ''); @@ -31,7 +32,7 @@ if (!items.length) return ''; return '
    '; } @@ -51,12 +52,12 @@ } }, { title: l('TenantName'), data: 'name', name: 'name', index: 1 }, - { title: 'Licence Plate', data: 'licencePlate', name: 'licencePlate', index: 2 }, + { title: lGm('TenantList:LicencePlate'), data: 'licencePlate', name: 'licencePlate', index: 2 }, { title: l('Division'), data: 'division', name: 'division', index: 3 }, { title: l('Branch'), data: 'branch', name: 'branch', index: 4 }, { title: l('Description'), data: 'description', name: 'description', index: 5 }, { - title: 'CAS Client Code', + title: lGm('TenantList:CasClientCode'), data: 'casClientCode', name: 'casClientCode', index: 6, @@ -346,7 +347,7 @@ $('#ConfigTenantAdminSearchButton').click(function (e) { e.preventDefault(); if ($('#config-search-value').val().trim().length < 2) { - abp.notify.warn('Please enter at least 2 characters to search.'); + abp.notify.warn(lGm('TenantList:SearchMinChars')); return; } _configFilterDataTable.ajax.reloadEx(); @@ -438,6 +439,10 @@ externalSearchId: 'search' }); + // Disable interactive row selection (selection is only ever driven via the API), + // without needing a "selectable" option on the shared initializeDataTable helper. + _dataTable.select.style('api'); + _createModal.onResult(function () { _dataTable.ajax.reloadEx(); }); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js index c242f8b161..c2d9448d3b 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js @@ -84,11 +84,65 @@ (function(){ - abp.utils.createNamespace(window, 'unity.tenantManagement.onboardingRequest'); + abp.utils.createNamespace(globalThis, 'unity.tenantManagement.onboardingRequest'); unity.tenantManagement.onboardingRequest.getList = function(input, ajaxParams) { + const qsParams = [ + { name: 'sorting', value: input.sorting }, + { name: 'skipCount', value: input.skipCount }, + { name: 'maxResultCount', value: input.maxResultCount }, + { name: 'category', value: input.category }, + { name: 'filter', value: input.filter } + ]; + (input.columnFilters || []).forEach(function(cf, i) { + qsParams.push( + { name: 'columnFilters[' + i + '].name', value: cf.name }, + { name: 'columnFilters[' + i + '].value', value: cf.value } + ); + }); return abp.ajax($.extend(true, { - url: abp.appPath + 'api/multi-tenancy/onboarding-requests' + abp.utils.buildQueryString([{ name: 'sorting', value: input.sorting }, { name: 'skipCount', value: input.skipCount }, { name: 'maxResultCount', value: input.maxResultCount }]) + '', + url: abp.appPath + 'api/onboarding-requests' + abp.utils.buildQueryString(qsParams) + '', + type: 'GET' + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.get = function(id, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/' + id + '', + type: 'GET' + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.validate = function(id, input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/' + id + '/validate' + abp.utils.buildQueryString([ + { name: 'tenantNameFieldKey', value: input?.tenantNameFieldKey }, + { name: 'superUsersFieldKey', value: input?.superUsersFieldKey } + ]) + '', + type: 'GET' + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.createTenant = function(id, input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/' + id + '/create-tenant', + type: 'POST', + data: JSON.stringify(input) + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.getColumnSchema = function(input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/column-schema' + abp.utils.buildQueryString([ + { name: 'category', value: input?.category } + ]) + '', + type: 'GET' + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.getAvailableCategories = function(ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/categories', type: 'GET' }, ajaxParams)); }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 01b0be6f94..729b62c172 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -13,10 +13,10 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.Flex.WorksheetInstances; -using Unity.Flex.Worksheets; -using Unity.AI.Models; -using Unity.AI.RateLimit; -using Unity.AI.Responses; +using Unity.Flex.Worksheets; +using Unity.AI.Models; +using Unity.AI.RateLimit; +using Unity.AI.Responses; using Unity.GrantManager.Applicants; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; @@ -37,6 +37,7 @@ using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.Features; +using Unity.Modules.Shared.Specializations; namespace Unity.GrantManager.GrantApplications; @@ -46,21 +47,21 @@ namespace Unity.GrantManager.GrantApplications; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(GrantApplicationAppService), typeof(IGrantApplicationAppService))] public class GrantApplicationAppService( - IApplicationManager applicationManager, - IApplicationRepository applicationRepository, - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, - IApplicationStatusRepository applicationStatusRepository, + IApplicationManager applicationManager, + IApplicationRepository applicationRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IApplicationStatusRepository applicationStatusRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicantRepository applicantRepository, IApplicationFormRepository applicationFormRepository, IApplicantAgentRepository applicantAgentRepository, IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, - IPaymentRequestAppService paymentRequestService, - IApplicationAIGenerationQueue aiGenerationQueue, - IAIGenerationStatusAppService aiGenerationStatusAppService, - IAIRateLimiter aiRateLimiter, - IFeatureChecker featureChecker) + IPaymentRequestAppService paymentRequestService, + IApplicationAIGenerationQueue aiGenerationQueue, + IAIGenerationStatusAppService aiGenerationStatusAppService, + IAIRateLimiter aiRateLimiter, + IFeatureChecker featureChecker) : GrantManagerAppService, IGrantApplicationAppService #pragma warning restore S107 // Methods should not have too many parameters { @@ -1106,6 +1107,16 @@ public async Task IsApplicantRedStopAsync(Guid applicationId) /// A list of application actions with their state machine permitted and authorization status. public async Task> GetActions(Guid applicationId, bool includeInternal = false) { + if (await featureChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) + { + var onboardingManager = LazyServiceProvider.LazyGetRequiredService(); + var onboardingActionList = await onboardingManager.GetActions(applicationId); + var onboardingDtos = ObjectMapper.Map, List>(onboardingActionList); + foreach (var item in onboardingDtos) + item.IsAuthorized = true; + return new ListResultDto(onboardingDtos); + } + var actionList = await applicationManager.GetActions(applicationId); var application = await applicationRepository.GetAsync(applicationId, true); @@ -1139,6 +1150,14 @@ private static OperationAuthorizationRequirement GetActionAuthorizationRequireme /// The action to be invoked on an Application public async Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction) { + if (await featureChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) + { + var onboardingManager = LazyServiceProvider.LazyGetRequiredService(); + var onboardingApplication = await onboardingManager.TriggerAction(applicationId, triggerAction); + await LocalEventBus.PublishAsync(new ApplicationChangedEvent { Action = triggerAction, ApplicationId = applicationId }); + return ObjectMapper.Map(onboardingApplication); + } + // AUTHORIZATION HANDLING var application = await applicationRepository.GetAsync(applicationId, true); if (!await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(triggerAction))) @@ -1165,14 +1184,14 @@ await LocalEventBus.PublishAsync( return ObjectMapper.Map(application); } - public async Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null) { await EnsureAIAnalysisEnabledAsync(); return await QueueApplicationAnalysisAsync(applicationId, promptVersion); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] - public async Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { await EnsureAIAnalysisEnabledAsync(); await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, CurrentTenant.Id, promptVersion); @@ -1182,28 +1201,28 @@ public async Task QueueApplicationAnalysisAsync(Guid appl AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, CurrentTenant.Id); - return await CreateGenerationStatusAsync( - request ?? throw new UserFriendlyException("Unable to queue AI analysis request.")); + return await CreateGenerationStatusAsync( + request ?? throw new UserFriendlyException("Unable to queue AI analysis request.")); } [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] - public async Task QueueAttachmentSummaryAsync(QueueAttachmentSummaryRequestDto input, string? promptVersion = null) - { - await EnsureAttachmentSummariesEnabledAsync(); - var attachmentIds = await ResolveAttachmentSummaryIdsAsync(input); - await aiGenerationQueue.QueueAttachmentSummaryAsync(input.ApplicationId, CurrentTenant.Id, promptVersion, attachmentIds); - - var request = await aiGenerationStatusAppService.GetLatestAsync( - input.ApplicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, - CurrentTenant.Id); - - return await CreateGenerationStatusAsync( - request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request.")); + public async Task QueueAttachmentSummaryAsync(QueueAttachmentSummaryRequestDto input, string? promptVersion = null) + { + await EnsureAttachmentSummariesEnabledAsync(); + var attachmentIds = await ResolveAttachmentSummaryIdsAsync(input); + await aiGenerationQueue.QueueAttachmentSummaryAsync(input.ApplicationId, CurrentTenant.Id, promptVersion, attachmentIds); + + var request = await aiGenerationStatusAppService.GetLatestAsync( + input.ApplicationId, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, + CurrentTenant.Id); + + return await CreateGenerationStatusAsync( + request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request.")); } [Authorize(AIPermissions.Analysis.GenerateScoring)] - public async Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { await EnsureScoringEnabledAsync(); await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, CurrentTenant.Id, promptVersion); @@ -1213,21 +1232,21 @@ public async Task QueueApplicationScoringAsync(Guid appli AIGenerationRequestKeyHelper.ApplicationScoringOperationType, CurrentTenant.Id); - return await CreateGenerationStatusAsync( - request ?? throw new UserFriendlyException("Unable to queue AI scoring request.")); + return await CreateGenerationStatusAsync( + request ?? throw new UserFriendlyException("Unable to queue AI scoring request.")); } - public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) + public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) { - await EnsureAIGenerationStatusAccessAsync(operationType); - return await CreateGenerationStatusAsync( - await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id)); + await EnsureAIGenerationStatusAccessAsync(operationType); + return await CreateGenerationStatusAsync( + await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id)); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [Authorize(AIPermissions.Analysis.GenerateScoring)] - public async Task QueueAllAIStagesAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueAllAIStagesAsync(Guid applicationId, string? promptVersion = null) { await EnsureAttachmentSummariesEnabledAsync(); await EnsureAIAnalysisEnabledAsync(); @@ -1239,20 +1258,20 @@ public async Task QueueAllAIStagesAsync(Guid applicationI AIGenerationRequestKeyHelper.PipelineOperationType, CurrentTenant.Id); - return await CreateGenerationStatusAsync( - request ?? throw new UserFriendlyException("Unable to queue AI generation request.")); - } - - private async Task CreateGenerationStatusAsync(AIGenerationRequestDto? request) - { - var state = await aiRateLimiter.GetStateAsync(); - return new AIGenerationStatusDto - { - GenerationRequest = request, - IsGenerating = state.IsGenerating, - RetryAfterSeconds = state.RetryAfterSeconds - }; - } + return await CreateGenerationStatusAsync( + request ?? throw new UserFriendlyException("Unable to queue AI generation request.")); + } + + private async Task CreateGenerationStatusAsync(AIGenerationRequestDto? request) + { + var state = await aiRateLimiter.GetStateAsync(); + return new AIGenerationStatusDto + { + GenerationRequest = request, + IsGenerating = state.IsGenerating, + RetryAfterSeconds = state.RetryAfterSeconds + }; + } private async Task EnsureAIGenerationStatusAccessAsync(string operationType) { @@ -1277,49 +1296,49 @@ private async Task EnsureAIGenerationStatusAccessAsync(string operationType) } } - private async Task EnsureAttachmentSummariesEnabledAsync() - { - if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) - { - throw new UserFriendlyException("AI attachment summaries are not enabled."); - } - } - - private async Task> ResolveAttachmentSummaryIdsAsync(QueueAttachmentSummaryRequestDto input) - { - if (input == null) - { - throw new UserFriendlyException("Attachment summary request is required."); - } - - if (input.ApplicationId == Guid.Empty) - { - throw new UserFriendlyException("Application id is required."); - } - - var applicationAttachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == input.ApplicationId)) - .Select(a => a.Id) - .ToList(); - - if (applicationAttachmentIds.Count == 0) - { - throw new UserFriendlyException("No attachments were found to generate summaries."); - } - - if (input.AttachmentIds is not { Count: > 0 }) - { - return applicationAttachmentIds; - } - - var applicationAttachmentIdSet = applicationAttachmentIds.ToHashSet(); - var selectedAttachmentIds = input.AttachmentIds.Distinct().ToList(); - if (selectedAttachmentIds.Any(id => !applicationAttachmentIdSet.Contains(id))) - { - throw new UserFriendlyException("One or more selected attachments do not belong to the application."); - } - - return selectedAttachmentIds; - } + private async Task EnsureAttachmentSummariesEnabledAsync() + { + if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) + { + throw new UserFriendlyException("AI attachment summaries are not enabled."); + } + } + + private async Task> ResolveAttachmentSummaryIdsAsync(QueueAttachmentSummaryRequestDto input) + { + if (input == null) + { + throw new UserFriendlyException("Attachment summary request is required."); + } + + if (input.ApplicationId == Guid.Empty) + { + throw new UserFriendlyException("Application id is required."); + } + + var applicationAttachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == input.ApplicationId)) + .Select(a => a.Id) + .ToList(); + + if (applicationAttachmentIds.Count == 0) + { + throw new UserFriendlyException("No attachments were found to generate summaries."); + } + + if (input.AttachmentIds is not { Count: > 0 }) + { + return applicationAttachmentIds; + } + + var applicationAttachmentIdSet = applicationAttachmentIds.ToHashSet(); + var selectedAttachmentIds = input.AttachmentIds.Distinct().ToList(); + if (selectedAttachmentIds.Any(id => !applicationAttachmentIdSet.Contains(id))) + { + throw new UserFriendlyException("One or more selected attachments do not belong to the application."); + } + + return selectedAttachmentIds; + } private async Task EnsureAIAnalysisEnabledAsync() { @@ -1480,4 +1499,4 @@ private static void UpdateFindingDismissedState(IEnumerable @@ -104,7 +104,7 @@ public async Task AutoImportUserInternalAsync(ImportUserDto importUserDto, } await UpdateAdditionalUserPropertiesAsync(identityUser, oidcSub, displayName); - await SyncUserToCurrentTenantAsync(newUserId, identityUser, oidcSub, displayName); + await SyncUserToCurrentTenantAsync(identityUser, oidcSub, displayName); } /// @@ -238,14 +238,14 @@ public async Task SetUserRolesAsync(Guid userId, string[] roleNames) return null; } - private async Task SyncUserToCurrentTenantAsync(Guid userId, IdentityUser user, string oidcSub, string displayName) + private async Task SyncUserToCurrentTenantAsync(IdentityUser user, string oidcSub, string displayName) { - var existingUser = await _personRepository.FindByOidcSub(oidcSub); + var existingUser = await _personRepository.FindByOidcSub(oidcSub); if (existingUser == null) { await _personRepository.InsertAsync(new Person() { - Id = userId, + Id = user.Id, OidcSub = oidcSub.ToSubjectWithoutIdp(), OidcDisplayName = displayName, FullName = $"{user.Name} {user.Surname}", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs new file mode 100644 index 0000000000..fa6a3112be --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs @@ -0,0 +1,188 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.TenantManagement; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.TenantManagement; + +[RemoteService(false)] +[ExposeServices(typeof(IOnboardingApplicationProvider))] +public class GrantManagerOnboardingApplicationProvider( + IRepository applicationRepository, + IRepository applicationFormRepository, + OnboardingApplicationManager onboardingApplicationManager) + : IOnboardingApplicationProvider, ITransientDependency +{ + private async Task> BuildQueryAsync(string category) + { + return (await applicationRepository.GetQueryableAsync()) + .AsNoTracking() + .Include(a => a.ApplicationForm) + .Include(a => a.ApplicationStatus) + .Where(a => a.ApplicationForm.Category == category); + } + + public async Task> GetPagedListAsync( + int skipCount, + int maxResultCount, + string? sorting, + string category, + string? filter = null, + IReadOnlyList? globalDynamicMatchIds = null, + IReadOnlyList? staticColumnFilters = null, + IReadOnlyList? dynamicColumnMatchIds = null) + { + var query = await BuildQueryAsync(category); + + // Dynamic column filter IDs — empty list means active filters matched nothing + if (dynamicColumnMatchIds != null) + { + if (dynamicColumnMatchIds.Count == 0) + return new PagedResultDto(0, []); + var dynSet = dynamicColumnMatchIds.ToHashSet(); + query = query.Where(a => dynSet.Contains(a.Id)); + } + + // Global text search: static fields OR pre-computed worksheet-matched IDs + if (!string.IsNullOrWhiteSpace(filter)) + { + if (globalDynamicMatchIds is { Count: > 0 }) + { + var dynSet = globalDynamicMatchIds.ToHashSet(); + query = query.Where(a => + a.ReferenceNo.Contains(filter) || + a.ApplicationStatus.InternalStatus.Contains(filter) || + (a.ApplicationForm.Category != null && a.ApplicationForm.Category.Contains(filter)) || + dynSet.Contains(a.Id)); + } + else + { + query = query.Where(a => + a.ReferenceNo.Contains(filter) || + a.ApplicationStatus.InternalStatus.Contains(filter) || + (a.ApplicationForm.Category != null && a.ApplicationForm.Category.Contains(filter))); + } + } + + // Per-column static filters from the FilterRow + if (staticColumnFilters != null) + { +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + // Need to suppress this because EF Core does not support StringComparison + foreach (var cf in staticColumnFilters.Where(cf => !string.IsNullOrWhiteSpace(cf.Value))) + { + var loweredValue = cf.Value.ToLowerInvariant(); + switch (cf.Name.ToLowerInvariant()) + { + case "submissionnumber": + query = query.Where(a => a.ReferenceNo.ToLower().Contains(loweredValue)); + break; + case "status": + query = query.Where(a => a.ApplicationStatus.InternalStatus.ToLower().Contains(loweredValue)); + break; + case "category": + query = query.Where(a => a.ApplicationForm.Category != null && a.ApplicationForm.Category.ToLower().Contains(loweredValue)); + break; + } + } +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + } + + var totalCount = await query.CountAsync(); + var ordered = ApplySorting(query, sorting); + + var items = await ordered + .Skip(skipCount) + .Take(maxResultCount) + .Select(a => new OnboardingApplicationRecord + { + Id = a.Id, + ReferenceNo = a.ReferenceNo, + SubmissionDate = a.SubmissionDate, + Status = a.ApplicationStatus.InternalStatus, + Category = a.ApplicationForm.Category ?? string.Empty + }) + .ToListAsync(); + + return new PagedResultDto(totalCount, items); + } + + public async Task GetByIdAsync(Guid id) + { + var query = (await applicationRepository.GetQueryableAsync()) + .AsNoTracking() + .Include(a => a.ApplicationForm) + .Include(a => a.ApplicationStatus); + + return await query + .Where(a => a.Id == id) + .Select(a => new OnboardingApplicationRecord + { + Id = a.Id, + ReferenceNo = a.ReferenceNo, + SubmissionDate = a.SubmissionDate, + Status = a.ApplicationStatus.InternalStatus, + Category = a.ApplicationForm.Category ?? string.Empty + }) + .FirstOrDefaultAsync(); + } + + public async Task> GetAllIdsAsync(string category) + { + var query = await BuildQueryAsync(category); + return await query.Select(a => a.Id).ToListAsync(); + } + + public async Task CloseApplicationAsync(Guid applicationId) + { + await onboardingApplicationManager.TriggerAction(applicationId, GrantApplicationAction.Close); + } + + public async Task> GetAvailableCategoriesAsync() + { + var query = await applicationFormRepository.GetQueryableAsync(); + return await query + .AsNoTracking() + .Where(f => f.Category != null && f.Category != string.Empty) + .Select(f => f.Category!) + .Distinct() + .OrderBy(c => c) + .ToListAsync(); + } + + private static IQueryable ApplySorting(IQueryable query, string? sorting) + { + if (string.IsNullOrWhiteSpace(sorting)) + return query.OrderByDescending(a => a.SubmissionDate); + + var parts = sorting.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var field = parts[0]; + var desc = parts.Length > 1 && parts[1].Equals("DESC", StringComparison.OrdinalIgnoreCase); + + return field.ToLowerInvariant() switch + { + "referenceno" or "submissionnumber" => desc + ? query.OrderByDescending(a => a.ReferenceNo) + : query.OrderBy(a => a.ReferenceNo), + "status" => desc + ? query.OrderByDescending(a => a.ApplicationStatus.InternalStatus) + : query.OrderBy(a => a.ApplicationStatus.InternalStatus), + "submissiondate" => desc + ? query.OrderByDescending(a => a.SubmissionDate) + : query.OrderBy(a => a.SubmissionDate), + "category" => desc + ? query.OrderByDescending(a => a.ApplicationForm.Category) + : query.OrderBy(a => a.ApplicationForm.Category), + _ => query.OrderByDescending(a => a.SubmissionDate) + }; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index aa4183d5fa..b867d8d95e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -523,6 +523,48 @@ "ApplicantPortalSettings:ChangesReset": "Changes have been reset to original values.", "ApplicantPortalSettings:SaveSuccess": "Portal status labels updated successfully.", "ApplicantPortalSettings:SaveError": "An error occurred while saving portal status labels.", - "ApplicantPortalSettings:ValidationRequired": "Portal status label cannot be empty." + "ApplicantPortalSettings:ValidationRequired": "Portal status label cannot be empty.", + + "Common:Command:Cancel": "Cancel", + "Common:Command:Save": "Save", + "Common:Command:Filter": "Filter", + + "OnboardingList:OpenButton": "Open", + "OnboardingList:CreateTenantButton": "Create Tenant", + "OnboardingList:FilterButton": "Filter", + "OnboardingList:CategoryLabel": "Category", + "Onboarding:ColumnSubmissionNumber": "Submission #", + "Onboarding:ColumnStatus": "Status", + "Onboarding:ColumnSubmissionDate": "Submission Date", + "Onboarding:ColumnCategory": "Category", + "Onboarding:ViewDataGrid": "View data grid", + "Onboarding:DataGridModalTitle": "Data Grid", + "OnboardingModal:ValidationPassed": "Validation passed. Ready to create tenant.", + "OnboardingModal:ValidationFailed": "Validation failed:", + "OnboardingModal:ValidateFailed": "Failed to validate request. Please try again.", + "OnboardingModal:CreateSuccess": "Tenant created successfully.", + "OnboardingModal:CreateFailed": "Failed to create tenant. Please try again.", + "CreateTenantModal:Title": "Create Tenant", + "CreateTenantModal:Ministry": "Ministry", + "CreateTenantModal:Branch": "Branch", + "CreateTenantModal:ProgramArea": "Program Area", + "CreateTenantModal:Category": "Category", + "CreateTenantModal:Features": "Features", + "CreateTenantModal:FieldMappingLabel": "Field mapping", + "CreateTenantModal:TenantNameFieldLabel": "Tenant name field", + "CreateTenantModal:SuperUsersFieldLabel": "Super users field", + "CreateTenantModal:MappingPlaceholder": "-- Select a field --", + "CreateTenantModal:RevalidateButton": "Re-validate", + "CreateTenantModal:Validating": "Validating request…", + "CreateTenantModal:CreatingSpinner": "Creating tenant, please wait…", + "CreateTenantModal:CreatingWarningTitle": "This may take a moment.", + "CreateTenantModal:CreatingWarningBody": "Tenant provisioning is in progress — please do not close this window or navigate away until the process completes.", + "CreateTenantModal:ConfirmButton": "Create Tenant", + "CreateTenantModal:NoFieldsWarning": "No worksheet data is available for this request. Tenant creation requires at least one submitted worksheet with field data.", + "TenantList:ActionsButton": "Actions", + "TenantList:ConfigurationAction": "Configuration", + "TenantList:LicencePlate": "Licence Plate", + "TenantList:CasClientCode": "CAS Client Code", + "TenantList:SearchMinChars": "Please enter at least 2 characters to search." } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs new file mode 100644 index 0000000000..aae3a375aa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs @@ -0,0 +1,91 @@ +using Stateless; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.Workflow; +using Volo.Abp.Domain.Services; + +namespace Unity.GrantManager.Applications; + +public class OnboardingApplicationManager( + IApplicationRepository applicationRepository, + IApplicationStatusRepository applicationStatusRepository) + : DomainService +{ + private static void ConfigureWorkflow(StateMachine sm) + { + sm.Configure(GrantApplicationState.SUBMITTED) + .Permit(GrantApplicationAction.Approve, GrantApplicationState.GRANT_APPROVED) + .Permit(GrantApplicationAction.Deny, GrantApplicationState.GRANT_NOT_APPROVED); + + sm.Configure(GrantApplicationState.GRANT_APPROVED) + .Permit(GrantApplicationAction.Close, GrantApplicationState.CLOSED); + + sm.Configure(GrantApplicationState.GRANT_NOT_APPROVED) + .Permit(GrantApplicationAction.Close, GrantApplicationState.CLOSED); + + sm.Configure(GrantApplicationState.CLOSED); + } + + public async Task> GetActions(Guid applicationId) + { + var application = await applicationRepository.GetAsync(applicationId, includeDetails: true); + var statusCode = application.ApplicationStatus.StatusCode; + + var workflow = new UnityWorkflow( + () => statusCode, + s => statusCode = s, + ConfigureWorkflow); + + var allActions = workflow.GetAllActions().Distinct().ToList(); + var permittedActions = workflow.GetPermittedActions().ToList(); + + return allActions + .Select(trigger => new ApplicationActionResultItem + { + ApplicationAction = trigger, + IsPermitted = permittedActions.Contains(trigger), + IsInternal = false + }) + .OrderBy(x => (int)x.ApplicationAction) + .ToList(); + } + + public bool IsActionAllowed(Application application, GrantApplicationAction triggerAction) + { + var statusCode = application.ApplicationStatus.StatusCode; + var workflow = new UnityWorkflow( + () => statusCode, + s => statusCode = s, + ConfigureWorkflow); + + return workflow.GetPermittedActions().Contains(triggerAction); + } + + public async Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction) + { + var application = await applicationRepository.GetAsync(applicationId, includeDetails: true); + var statusCode = application.ApplicationStatus.StatusCode; + + var workflow = new UnityWorkflow( + () => statusCode, + s => statusCode = s, + ConfigureWorkflow); + + await workflow.ExecuteActionAsync(triggerAction); + + var newStatus = await applicationStatusRepository.GetAsync(x => x.StatusCode.Equals(statusCode)); + application.ApplicationStatusId = newStatus.Id; + application.ApplicationStatus = newStatus; + application.LastModificationTime = DateTime.UtcNow; + + if (triggerAction == GrantApplicationAction.Approve || triggerAction == GrantApplicationAction.Deny) + { + application.FinalDecisionDate = DateTime.UtcNow; + } + + return await applicationRepository.UpdateAsync(application); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 21f6b26270..f2d369af42 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -614,6 +614,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex }); app.UseAuditing(); app.UseAbpSerilogEnrichers(); + app.UseMiddleware(); app.UseConfiguredEndpoints(); var supportedCultures = new[] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/OnboardingRedirectMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/OnboardingRedirectMiddleware.cs new file mode 100644 index 0000000000..2541657153 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/OnboardingRedirectMiddleware.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Unity.Modules.Shared.Specializations; + +namespace Unity.GrantManager.Web.Middleware; + +public class OnboardingRedirectMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + if (IsGrantApplicationsList(context)) + { + var checker = context.RequestServices.GetService(); + if (checker != null && await checker.IsEnabledAsync(SpecializationConsts.Onboarding)) + { + context.Response.Redirect("/TenantManagement/Onboarding"); + return; + } + } + + await next(context); + } + + private static bool IsGrantApplicationsList(HttpContext context) + { + var path = context.Request.Path; + return context.Request.Method == HttpMethods.Get + && (path.Equals("/GrantApplications", StringComparison.OrdinalIgnoreCase) + || path.Equals("/GrantApplications/", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index f7264249c6..9181e20437 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -18,6 +18,7 @@ using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Zones; using Unity.Modules.Shared.Correlation; +using Unity.Modules.Shared.Specializations; using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; using Volo.Abp.Features; using Volo.Abp.Users; @@ -114,6 +115,11 @@ public DetailsModel( public async Task OnGetAsync() { + if (await _featureChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) + { + ViewData["ActiveNavHref"] = "/TenantManagement/Onboarding"; + } + ApplicationFormSubmission applicationFormSubmission = await _grantApplicationAppService.GetFormSubmissionByApplicationId(ApplicationId); ZoneStateSet = await _zoneManagementAppService.GetZoneStateSetAsync(applicationFormSubmission.ApplicationFormId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.js index 2d7fc573ea..ac06939a24 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.js @@ -1,4 +1,6 @@ (function () { + const l = abp.localization.getResource('GrantManager'); + abp.widgets.ApplicationActionWidget = function ($wrapper) { let widgetManager = $wrapper.data('abp-widget-manager'); From 52fe3d058bf7537136c7bf2aa6312f70456aad15 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 18 Jun 2026 17:08:29 -0700 Subject: [PATCH 05/51] AB#33309 update unit tests --- .../Worksheets/WorksheetInstanceAppService.cs | 6 + .../WorksheetInstanceRepositoryTests.cs | 117 +++++++ .../TenantCreateDto.cs | 5 +- .../OnboardingRequestAppService.cs | 27 +- .../Properties/AssemblyInfo.cs | 2 + .../TenantAppService.cs | 1 + .../TenantManagement/Onboarding/Index.js | 2 +- .../Onboarding/OnboardingFeatureMapTests.cs | 73 +++++ .../OnboardingRequestAppServiceTests.cs | 292 ++++++++++++++++++ .../OnboardingValidationStepsTests.cs | 119 +++++++ .../Handlers/TenantCreatedEventHandler.cs | 20 +- .../TenantCreatedEventHandlerTests.cs | 45 +++ ...nagerOnboardingApplicationProviderTests.cs | 159 ++++++++++ 13 files changed, 851 insertions(+), 17 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/WorksheetInstanceRepositoryTests.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Properties/AssemblyInfo.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Handlers/TenantCreatedEventHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs index 8a5d534526..7c760c2560 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization; using Unity.Flex.Domain.WorksheetInstances; using Unity.Flex.Domain.Services; +using Volo.Abp; using Volo.Abp.Application.Dtos; namespace Unity.Flex.WorksheetInstances @@ -33,6 +34,7 @@ public virtual async Task UpdateAsync(PersistWorksheetIntanceValuesDto dto) await worksheetsManager.PersistWorksheetData(ObjectMapper.Map(dto)); } + [RemoteService(false)] public virtual async Task> GetListByCorrelationIdsAsync(List correlationIds, string correlationProvider) { var instances = await worksheetInstanceRepository.GetByCorrelationIdsAsync(correlationIds, correlationProvider); @@ -44,16 +46,19 @@ public virtual async Task> GetListByCorrelationId }).ToList(); } + [RemoteService(false)] public virtual async Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider) { return await worksheetInstanceRepository.GetDistinctWorksheetIdsByCorrelationProviderAsync(correlationProvider); } + [RemoteService(false)] public virtual async Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(List correlationIds, string correlationProvider) { return await worksheetInstanceRepository.GetDistinctWorksheetIdsByCorrelationIdsAsync(correlationIds, correlationProvider); } + [RemoteService(false)] public virtual async Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount) { var totalCount = await worksheetInstanceRepository.GetCountByCorrelationProviderAsync(correlationProvider); @@ -69,6 +74,7 @@ public virtual async Task> GetPagedList return new PagedResultDto(totalCount, items); } + [RemoteService(false)] public virtual async Task GetDataByIdAsync(Guid id) { var wi = await worksheetInstanceRepository.FindAsync(id); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/WorksheetInstanceRepositoryTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/WorksheetInstanceRepositoryTests.cs new file mode 100644 index 0000000000..e0bab07bb1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/WorksheetInstanceRepositoryTests.cs @@ -0,0 +1,117 @@ +using Shouldly; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.Flex.Domain.WorksheetInstances; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.Flex.Worksheets +{ + public class WorksheetInstanceRepositoryTests : FlexApplicationTestBase + { + private readonly IWorksheetInstanceRepository _worksheetInstanceRepository; + private readonly ICurrentTenant _currentTenant; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public WorksheetInstanceRepositoryTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + _worksheetInstanceRepository = GetRequiredService(); + _currentTenant = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetByCorrelationIdsAsync_OnlyReturnsRowsForTheExactCorrelationProviderRequested() + { + var correlationId = Guid.NewGuid(); + + await _worksheetInstanceRepository.InsertAsync(new WorksheetInstance( + Guid.NewGuid(), Guid.NewGuid(), correlationId, "Application", + Guid.NewGuid(), "FormVersion", "anchor"), autoSave: true); + await _worksheetInstanceRepository.InsertAsync(new WorksheetInstance( + Guid.NewGuid(), Guid.NewGuid(), correlationId, "OtherProvider", + Guid.NewGuid(), "FormVersion", "anchor"), autoSave: true); + + var result = await _worksheetInstanceRepository.GetByCorrelationIdsAsync([correlationId], "Application"); + + result.ShouldHaveSingleItem(); + result[0].CorrelationProvider.ShouldBe("Application"); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetByCorrelationIdsAsync_RespectsMultiTenantFilter() + { + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var correlationId = Guid.NewGuid(); + + using (_currentTenant.Change(tenantA)) + { + using var uow = _unitOfWorkManager.Begin(); + await _worksheetInstanceRepository.InsertAsync(new WorksheetInstance( + Guid.NewGuid(), Guid.NewGuid(), correlationId, "Application", + Guid.NewGuid(), "FormVersion", "anchor"), autoSave: true); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(tenantA)) + { + using var uow = _unitOfWorkManager.Begin(); + var visibleToOwnTenant = await _worksheetInstanceRepository.GetByCorrelationIdsAsync([correlationId], "Application"); + visibleToOwnTenant.ShouldHaveSingleItem(); + } + + using (_currentTenant.Change(tenantB)) + { + using var uow = _unitOfWorkManager.Begin(); + var visibleToOtherTenant = await _worksheetInstanceRepository.GetByCorrelationIdsAsync([correlationId], "Application"); + visibleToOtherTenant.ShouldBeEmpty(); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetDistinctWorksheetIdsByCorrelationIdsAsync_OnlyReturnsIdsForTheExactCorrelationProviderRequested() + { + var correlationId = Guid.NewGuid(); + var worksheetIdForRequestedProvider = Guid.NewGuid(); + var worksheetIdForOtherProvider = Guid.NewGuid(); + + await _worksheetInstanceRepository.InsertAsync(new WorksheetInstance( + Guid.NewGuid(), worksheetIdForRequestedProvider, correlationId, "Application", + Guid.NewGuid(), "FormVersion", "anchor"), autoSave: true); + await _worksheetInstanceRepository.InsertAsync(new WorksheetInstance( + Guid.NewGuid(), worksheetIdForOtherProvider, correlationId, "OtherProvider", + Guid.NewGuid(), "FormVersion", "anchor"), autoSave: true); + + var result = await _worksheetInstanceRepository.GetDistinctWorksheetIdsByCorrelationIdsAsync([correlationId], "Application"); + + result.ShouldBe([worksheetIdForRequestedProvider]); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListByCorrelationProviderAsync_OnlyReturnsRowsForTheExactCorrelationProviderRequested() + { + var marker = Guid.NewGuid().ToString("N"); + var correlationProvider = $"Provider-{marker}"; + + await _worksheetInstanceRepository.InsertAsync(new WorksheetInstance( + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), correlationProvider, + Guid.NewGuid(), "FormVersion", "anchor"), autoSave: true); + await _worksheetInstanceRepository.InsertAsync(new WorksheetInstance( + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "OtherProvider", + Guid.NewGuid(), "FormVersion", "anchor"), autoSave: true); + + var result = await _worksheetInstanceRepository.GetPagedListByCorrelationProviderAsync(correlationProvider, 0, 10); + + result.ShouldHaveSingleItem(); + result[0].CorrelationProvider.ShouldBe(correlationProvider); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs index 1fa985a095..d9b7998e85 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs @@ -1,8 +1,9 @@ -namespace Unity.TenantManagement; +#nullable enable +namespace Unity.TenantManagement; public class TenantCreateDto : TenantCreateOrUpdateDtoBase { - public string UserIdentifier { get; set; } + public string UserIdentifier { get; set; } = string.Empty; /// Comma-separated ABP feature keys to enable on the new tenant (e.g. "Unity.Payments,Unity.Reporting"). public string? FeatureKeys { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs index c224bf7a34..3e607cb7c4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -231,13 +231,7 @@ public virtual async Task ValidateAsync(Guid id, await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); - var issues = new List(); - foreach (var step in _validationSteps.OrderBy(s => s.Order)) - { - var stepResult = await step.ValidateAsync(request); - if (!stepResult.IsValid && stepResult.Issue is not null) - issues.Add($"[{step.StepName}] {stepResult.Issue}"); - } + var issues = await RunValidationStepsAsync(request); return new OnboardingValidationResultDto { IsValid = issues.Count == 0, Issues = issues }; } @@ -252,6 +246,13 @@ public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input if (input != null) await SaveFieldMappingAsync(input.TenantNameFieldKey, input.SuperUsersFieldKey, input.BranchFieldKey, input.FeaturesFieldKey, input.MinistryFieldKey, input.ProgramAreaFieldKey); + // Re-validate server-side even if the client already called ValidateAsync — the client + // cannot be trusted to have done so, and skipping this would let a duplicate tenant name + // or other validation-step failure slip through directly via this endpoint. + var validationIssues = await RunValidationStepsAsync(request); + if (validationIssues.Count > 0) + throw new UserFriendlyException(string.Join(" ", validationIssues)); + var emails = SuperUsersValidationStep.ParseEmails(request.SuperUsers); var userGuids = new List(); @@ -292,6 +293,18 @@ await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto await ApplicationProvider.CloseApplicationAsync(id); } + private async Task> RunValidationStepsAsync(OnboardingRequestDto request) + { + var issues = new List(); + foreach (var step in _validationSteps.OrderBy(s => s.Order)) + { + var stepResult = await step.ValidateAsync(request); + if (!stepResult.IsValid && stepResult.Issue is not null) + issues.Add($"[{step.StepName}] {stepResult.Issue}"); + } + return issues; + } + private async Task ResolveFieldMappings(OnboardingRequestDto request, string? tenantNameKey = null, string? superUsersKey = null, string? branchKey = null, string? featuresKey = null, diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Properties/AssemblyInfo.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..145fb8527b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Properties/AssemblyInfo.cs @@ -0,0 +1,2 @@ +using System.Runtime.CompilerServices; +[assembly:InternalsVisibleToAttribute("Unity.TenantManagement.Application.Tests")] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 749f8efcc0..8fdddf92a4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -264,6 +264,7 @@ public async Task GetCurrentTenantName() return tenant?.Name ?? string.Empty; } + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] public async Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) { await localEventBus.PublishAsync( diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js index 64190e0278..9d7ba155b0 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -90,7 +90,7 @@ const keys = []; grid.rows.forEach(function (row) { (row.cells || []).forEach(function (cell) { - if (keys.indexOf(cell.key) === -1) keys.push(cell.key); + if (!keys.includes(cell.key)) keys.push(cell.key); }); }); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs new file mode 100644 index 0000000000..d0e186bb17 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs @@ -0,0 +1,73 @@ +using Shouldly; +using Xunit; + +namespace Unity.TenantManagement.Onboarding; + +public class OnboardingFeatureMapTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveFeatureKeys_BlankInput_ReturnsEmpty(string? input) + { + var result = OnboardingFeatureMap.ResolveFeatureKeys(input!); + + result.ShouldBeEmpty(); + } + + [Fact] + public void ResolveFeatureKeys_UnrecognizedTokens_AreDroppedNotPassedThrough() + { + // The "Features" worksheet field is filled out by the applicant submitting the onboarding + // request, so unrecognized/arbitrary tokens must never reach the real ABP feature name list. + var result = OnboardingFeatureMap.ResolveFeatureKeys("Payments,SomeArbitraryFeatureKey,DROP TABLE Tenants"); + + result.ShouldBe(["Unity.Payments"]); + } + + [Theory] + [InlineData("Payments,Reporting")] + [InlineData("Payments;Reporting")] + [InlineData("Payments|Reporting")] + public void ResolveFeatureKeys_AllDelimiterVariants_AreSupported(string input) + { + var result = OnboardingFeatureMap.ResolveFeatureKeys(input); + + result.ShouldBe(["Unity.Payments", "Unity.Reporting"]); + } + + [Fact] + public void ResolveFeatureKeys_KeyMatchingIsCaseInsensitive() + { + var result = OnboardingFeatureMap.ResolveFeatureKeys("payments,AIREPORTING"); + + result.ShouldBe(["Unity.Payments", "Unity.AIReporting"]); + } + + [Fact] + public void ResolveFeatureKeys_DuplicateTokens_CollapseToASingleKey() + { + var result = OnboardingFeatureMap.ResolveFeatureKeys("Payments,Payments,Flex"); + + result.ShouldBe(["Unity.Payments", "Unity.Flex"]); + } + + [Fact] + public void ResolveFeatureKeys_CheckboxGroupJsonFormat_OnlyEnabledKeysAreResolved() + { + var json = """[{"key":"aiReporting","value":true},{"key":"aiScoring","value":false}]"""; + + var result = OnboardingFeatureMap.ResolveFeatureKeys(json); + + result.ShouldBe(["Unity.AIReporting"]); + } + + [Fact] + public void ResolveFeatureKeys_MalformedJsonArray_DoesNotThrowAndReturnsEmpty() + { + var result = OnboardingFeatureMap.ResolveFeatureKeys("[ this is not valid json"); + + result.ShouldBeEmpty(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs new file mode 100644 index 0000000000..477bc2524f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Values; +using Unity.Flex.WorksheetInstances; +using Unity.TenantManagement.Onboarding; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.SettingManagement; +using Xunit; + +namespace Unity.TenantManagement; + +public class OnboardingRequestAppServiceTests : AbpTenantManagementApplicationTestBase +{ + private const string ApplicationCorrelationProvider = "Application"; + + private IOnboardingApplicationProvider _applicationProvider = null!; + private IWorksheetInstanceAppService _worksheetInstanceAppService = null!; + private IWorksheetAppService _worksheetAppService = null!; + private IOnboardingUserLookup _userLookup = null!; + private ITenantAppService _tenantAppService = null!; + private ISettingManager _settingManager = null!; + + private readonly IOnboardingRequestAppService _appService; + + protected override void AfterAddApplication(IServiceCollection services) + { + _applicationProvider = Substitute.For(); + _applicationProvider.GetAllIdsAsync(Arg.Any()).Returns(new List()); + _applicationProvider.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any?>(), Arg.Any?>()) + .Returns(new PagedResultDto(0, [])); + + _worksheetInstanceAppService = Substitute.For(); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), Arg.Any()) + .Returns(new List()); + + _worksheetAppService = Substitute.For(); + _userLookup = Substitute.For(); + _tenantAppService = Substitute.For(); + + _settingManager = Substitute.For(); + _settingManager.GetOrNullAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((string?)null); + + services.AddSingleton(_applicationProvider); + services.AddSingleton(_worksheetInstanceAppService); + services.AddSingleton(_worksheetAppService); + services.AddSingleton(_userLookup); + services.AddSingleton(_tenantAppService); + services.AddSingleton(_settingManager); + } + + public OnboardingRequestAppServiceTests() + { + _appService = GetRequiredService(); + } + + private static WorksheetInstanceDataDto WorksheetInstanceFor(Guid correlationId, params (string Key, string Value)[] fields) => + new() + { + Id = Guid.NewGuid(), + CorrelationId = correlationId, + WorksheetId = Guid.NewGuid(), + CurrentValue = System.Text.Json.JsonSerializer.Serialize(new WorksheetInstanceValue + { + Values = fields.Select(f => new FieldInstanceValue(f.Key, f.Value)).ToList() + }) + }; + + [Fact] + public async Task GetListAsync_DynamicColumnFilters_AreCaseInsensitiveAndAndCombined() + { + var matching = Guid.NewGuid(); + var wrongMinistry = Guid.NewGuid(); + var wrongBranch = Guid.NewGuid(); + var allIds = new List { matching, wrongMinistry, wrongBranch }; + + _applicationProvider.GetAllIdsAsync("Onboarding").Returns(allIds); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(matching, ("ministry", "Health"), ("branch", "North")), + WorksheetInstanceFor(wrongMinistry, ("ministry", "Finance"), ("branch", "North")), + WorksheetInstanceFor(wrongBranch, ("ministry", "Health"), ("branch", "South")) + }); + + IReadOnlyList? capturedMatchIds = null; + _applicationProvider + .When(p => p.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any?>(), Arg.Any?>())) + .Do(call => capturedMatchIds = call.ArgAt?>(7)); + + await _appService.GetListAsync(new OnboardingListRequestDto + { + ColumnFilters = [ + new ColumnFilterDto { Name = "ministry", Value = "HEALTH" }, + new ColumnFilterDto { Name = "branch", Value = "nor" } + ] + }); + + capturedMatchIds.ShouldNotBeNull(); + capturedMatchIds.ShouldBe([matching]); + } + + [Fact] + public async Task GetListAsync_DynamicColumnSort_StripsFieldsPrefixAndSortsInMemoryWithPaging() + { + var idA = Guid.NewGuid(); + var idB = Guid.NewGuid(); + var idC = Guid.NewGuid(); + var allIds = new List { idA, idB, idC }; + + _applicationProvider.GetAllIdsAsync("Onboarding").Returns(allIds); + _applicationProvider.GetPagedListAsync(0, int.MaxValue, null, "Onboarding", null, null, null, null) + .Returns(new PagedResultDto(3, [ + new OnboardingApplicationRecord { Id = idA, Category = "Onboarding" }, + new OnboardingApplicationRecord { Id = idB, Category = "Onboarding" }, + new OnboardingApplicationRecord { Id = idC, Category = "Onboarding" } + ])); + + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(idA, ("score", "Charlie")), + WorksheetInstanceFor(idB, ("score", "Apple")), + WorksheetInstanceFor(idC, ("score", "Bravo")) + }); + + var result = await _appService.GetListAsync(new OnboardingListRequestDto + { + Sorting = "fields.score asc", + SkipCount = 0, + MaxResultCount = 2 + }); + + result.TotalCount.ShouldBe(3); + result.Items.Select(i => i.Id).ShouldBe([idB, idC]); + } + + [Fact] + public async Task GetColumnSchemaAsync_DedupesFieldsAcrossWorksheets_PreservesOrder() + { + var appId = Guid.NewGuid(); + var ws1 = Guid.NewGuid(); + var ws2 = Guid.NewGuid(); + + _applicationProvider.GetAllIdsAsync("Onboarding").Returns(new List { appId }); + _worksheetInstanceAppService.GetDistinctWorksheetIdsByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider) + .Returns(new List { ws1, ws2 }); + + _worksheetAppService.GetAsync(ws1).Returns(new WorksheetDto + { + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, + new CustomFieldDto { Key = "ministry", Label = "Ministry", Order = 1, Enabled = true } + ]} + ] + }); + _worksheetAppService.GetAsync(ws2).Returns(new WorksheetDto + { + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, // duplicate + new CustomFieldDto { Key = "hidden", Label = "Hidden", Order = 1, Enabled = false }, // disabled + new CustomFieldDto { Key = "email", Label = "Email", Order = 2, Enabled = true } + ]} + ] + }); + + var result = await _appService.GetColumnSchemaAsync(); + + result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ministry", "email"]); + } + + [Fact] + public async Task ValidateAsync_ExplicitTenantNameFieldKey_OverridesSavedMapping() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("explicitKey", "Brand New Co"), ("savedKey", "acme")) + }); + + // Simulates a previously saved column mapping pointing at a different (colliding) field. + _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("savedKey"); + + var result = await _appService.ValidateAsync(id, tenantNameFieldKey: "explicitKey", superUsersFieldKey: null); + + result.Issues.ShouldNotContain(i => i.StartsWith("[Tenant Name]")); + } + + [Fact] + public async Task ValidateAsync_AggregatesFailuresFromAllStepsInOrder() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "acme"), ("su", "not-an-email")) + }); + + var result = await _appService.ValidateAsync(id, tenantNameFieldKey: "tn", superUsersFieldKey: "su"); + + result.IsValid.ShouldBeFalse(); + result.Issues.Count.ShouldBe(2); + result.Issues[0].ShouldStartWith("[Tenant Name]"); + result.Issues[1].ShouldStartWith("[Super Users]"); + } + + [Fact] + public async Task CreateTenantAsync_NoValidSuperUsers_ThrowsAndDoesNotCreateTenant() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Brand New Co"), ("su", "not-an-email")) + }); + + await Should.ThrowAsync(() => _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su" + })); + + await _tenantAppService.DidNotReceive().CreateAsync(Arg.Any()); + } + + [Fact] + public async Task CreateTenantAsync_DuplicateTenantName_ThrowsAndDoesNotCreateTenant() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + // "acme" is seeded by the test host, so this collides even though super users resolve fine. + WorksheetInstanceFor(id, ("tn", "acme"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + + await Should.ThrowAsync(() => _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su" + })); + + await _tenantAppService.DidNotReceive().CreateAsync(Arg.Any()); + await _applicationProvider.DidNotReceive().CloseApplicationAsync(Arg.Any()); + } + + [Fact] + public async Task CreateTenantAsync_ValidSuperUsers_CreatesTenantAndAssignsRemainingAsManagers() + { + var id = Guid.NewGuid(); + var newTenantId = Guid.NewGuid(); + + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "New Co"), ("su", "first@example.com,second@example.com"), ("branch", "North")) + }); + + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _userLookup.FindUserGuidByEmailAsync("second@example.com").Returns("guid-2"); + + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "New Co" }); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + BranchFieldKey = "branch" + }); + + await _tenantAppService.Received(1).CreateAsync(Arg.Is(d => + d.Name == "New Co" && d.Branch == "North" && d.UserIdentifier == "guid-1")); + await _tenantAppService.Received(1).AssignManagerAsync(Arg.Is(d => + d.TenantId == newTenantId && d.UserIdentifier == "guid-2")); + await _applicationProvider.Received(1).CloseApplicationAsync(id); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs new file mode 100644 index 0000000000..33259adcd7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using NSubstitute; +using Shouldly; +using Unity.TenantManagement; +using Volo.Abp.TenantManagement; +using Xunit; + +namespace Unity.TenantManagement.Validation; + +public class SuperUsersValidationStepTests +{ + private static OnboardingRequestDto RequestWithSuperUsers(string superUsers) => + new() { SuperUsers = superUsers }; + + [Fact] + public async Task ValidateAsync_NoEmailsParsed_ReturnsFailure() + { + var lookup = Substitute.For(); + var step = new SuperUsersValidationStep(lookup); + + var result = await step.ValidateAsync(RequestWithSuperUsers("not an email")); + + result.IsValid.ShouldBeFalse(); + await lookup.DidNotReceive().FindUserGuidByEmailAsync(Arg.Any()); + } + + [Fact] + public async Task ValidateAsync_AnyParsedEmailResolves_ReturnsSuccess() + { + var lookup = Substitute.For(); + lookup.FindUserGuidByEmailAsync("first@example.com").Returns((string?)null); + lookup.FindUserGuidByEmailAsync("second@example.com").Returns("guid-123"); + var step = new SuperUsersValidationStep(lookup); + + var result = await step.ValidateAsync(RequestWithSuperUsers("first@example.com; second@example.com")); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public async Task ValidateAsync_NoneOfTheParsedEmailsResolve_ReturnsFailure() + { + var lookup = Substitute.For(); + lookup.FindUserGuidByEmailAsync(Arg.Any()).Returns((string?)null); + var step = new SuperUsersValidationStep(lookup); + + var result = await step.ValidateAsync(RequestWithSuperUsers("first@example.com,second@example.com")); + + result.IsValid.ShouldBeFalse(); + result.Issue.ShouldNotBeNullOrEmpty(); + } + + [Theory] + [InlineData("a@example.com,b@example.com", new[] { "a@example.com", "b@example.com" })] + [InlineData("a@example.com;b@example.com", new[] { "a@example.com", "b@example.com" })] + [InlineData("a@example.com|b@example.com", new[] { "a@example.com", "b@example.com" })] + [InlineData(" a@example.com , not-an-email , b@example.com ", new[] { "a@example.com", "b@example.com" })] + public void ParseEmails_HandlesDelimitersAndDropsNonEmailTokens(string input, string[] expected) + { + var result = SuperUsersValidationStep.ParseEmails(input); + + result.ShouldBe(expected); + } +} + +public class TenantNameUniquenessStepTests +{ + private static OnboardingRequestDto RequestWithTenantName(string? tenantName) => + new() { TenantName = tenantName ?? string.Empty }; + + // Tenant's (Guid, string, string) constructor is internal to the ABP assembly; reflection is the + // only way to build a real instance here, since this test only needs a non-null "found" result. + private static Tenant NewTenant(string name) => + (Tenant)typeof(Tenant) + .GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, [typeof(Guid), typeof(string), typeof(string)])! + .Invoke([Guid.NewGuid(), name, name.ToUpperInvariant()]); + + [Fact] + public async Task ValidateAsync_BlankTenantName_ReturnsFailureWithoutQueryingRepository() + { + var tenantRepository = Substitute.For(); + var step = new TenantNameUniquenessStep(tenantRepository); + + var result = await step.ValidateAsync(RequestWithTenantName(" ")); + + result.IsValid.ShouldBeFalse(); + await tenantRepository.DidNotReceive().FindByNameAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ValidateAsync_NameAlreadyExists_ReturnsFailure() + { + var tenantRepository = Substitute.For(); + var existing = NewTenant("Acme"); + tenantRepository.FindByNameAsync("ACME", Arg.Any(), Arg.Any()) + .Returns(existing); + var step = new TenantNameUniquenessStep(tenantRepository); + + var result = await step.ValidateAsync(RequestWithTenantName("Acme")); + + result.IsValid.ShouldBeFalse(); + result.Issue.ShouldContain("Acme"); + } + + [Fact] + public async Task ValidateAsync_NameIsUnique_ReturnsSuccess() + { + var tenantRepository = Substitute.For(); + tenantRepository.FindByNameAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Tenant?)null); + var step = new TenantNameUniquenessStep(tenantRepository); + + var result = await step.ValidateAsync(RequestWithTenantName("Brand New Co")); + + result.IsValid.ShouldBeTrue(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs index f6425a24f2..7f725c1034 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs @@ -53,15 +53,10 @@ await _userImportAppService.ImportUserAsync(new ImportUserDto() private async Task EnableRequestedFeaturesAsync(TenantCreatedEto eto, Guid tenantId) { - if (!eto.Properties.TryGetValue("FeatureKeys", out var featureKeysRaw) || - string.IsNullOrWhiteSpace(featureKeysRaw)) + if (!eto.Properties.TryGetValue("FeatureKeys", out var featureKeysRaw)) return; - var featureUpdates = featureKeysRaw - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(key => new UpdateFeatureDto { Name = key, Value = "true" }) - .ToList(); - + var featureUpdates = BuildFeatureUpdates(featureKeysRaw); if (featureUpdates.Count == 0) return; await _featureAppService.UpdateAsync( @@ -69,5 +64,16 @@ await _featureAppService.UpdateAsync( tenantId.ToString(), new UpdateFeaturesDto { Features = featureUpdates }); } + + internal static List BuildFeatureUpdates(string? featureKeysRaw) + { + if (string.IsNullOrWhiteSpace(featureKeysRaw)) + return []; + + return featureKeysRaw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(key => new UpdateFeatureDto { Name = key, Value = "true" }) + .ToList(); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Handlers/TenantCreatedEventHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Handlers/TenantCreatedEventHandlerTests.cs new file mode 100644 index 0000000000..68ad2398d1 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Handlers/TenantCreatedEventHandlerTests.cs @@ -0,0 +1,45 @@ +using Shouldly; +using Xunit; + +namespace Unity.GrantManager.Handlers; + +public class TenantCreatedEventHandlerTests +{ + [Fact] + public void BuildFeatureUpdates_NullInput_ReturnsEmpty() + { + var result = TenantCreatedEventHandler.BuildFeatureUpdates(null); + + result.ShouldBeEmpty(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BuildFeatureUpdates_BlankInput_ReturnsEmpty(string featureKeysRaw) + { + var result = TenantCreatedEventHandler.BuildFeatureUpdates(featureKeysRaw); + + result.ShouldBeEmpty(); + } + + [Fact] + public void BuildFeatureUpdates_CommaSeparatedKeys_ReturnsOneEnabledUpdatePerKey() + { + var result = TenantCreatedEventHandler.BuildFeatureUpdates("Unity.Payments, Unity.Reporting ,Unity.Notifications"); + + result.Count.ShouldBe(3); + result.ShouldAllBe(f => f.Value == "true"); + result.ShouldContain(f => f.Name == "Unity.Payments"); + result.ShouldContain(f => f.Name == "Unity.Reporting"); + result.ShouldContain(f => f.Name == "Unity.Notifications"); + } + + [Fact] + public void BuildFeatureUpdates_EmptyEntriesBetweenDelimiters_AreIgnored() + { + var result = TenantCreatedEventHandler.BuildFeatureUpdates("Unity.Payments,, ,Unity.Reporting"); + + result.Count.ShouldBe(2); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs new file mode 100644 index 0000000000..7f1d7f3af9 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shouldly; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.TenantManagement; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.TenantManagement; + +public class GrantManagerOnboardingApplicationProviderTests : GrantManagerApplicationTestBase +{ + private readonly IOnboardingApplicationProvider _provider; + private readonly IRepository _applicationRepository; + private readonly IRepository _applicationFormRepository; + private readonly IRepository _applicationStatusRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public GrantManagerOnboardingApplicationProviderTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + _provider = GetRequiredService(); + _applicationRepository = GetRequiredService>(); + _applicationFormRepository = GetRequiredService>(); + _applicationStatusRepository = GetRequiredService>(); + _unitOfWorkManager = GetRequiredService(); + } + + private async Task CreateFormAsync(string category) + { + var form = await _applicationFormRepository.InsertAsync(new ApplicationForm + { + IntakeId = GrantManagerTestData.Intake1_Id, + ApplicationFormName = "Onboarding Provider Test Form", + Category = category + }, autoSave: true); + return form.Id; + } + + private async Task CreateStatusAsync(string internalStatus, GrantApplicationState statusCode) => + (await _applicationStatusRepository.InsertAsync(new ApplicationStatus + { + InternalStatus = internalStatus, + ExternalStatus = internalStatus, + StatusCode = statusCode + }, autoSave: true)).Id; + + private async Task CreateApplicationAsync( + Guid applicationFormId, Guid applicationStatusId, string referenceNo, DateTime submissionDate) => + await _applicationRepository.InsertAsync(new Application + { + ApplicationFormId = applicationFormId, + ApplicationStatusId = applicationStatusId, + ApplicantId = GrantManagerTestData.Applicant1_Id, + ReferenceNo = referenceNo, + SubmissionDate = submissionDate + }, autoSave: true); + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_NoSorting_DefaultsToSubmissionDateDescending() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + + var older = await CreateApplicationAsync(formId, statusId, "REF-OLD", new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var newer = await CreateApplicationAsync(formId, statusId, "REF-NEW", new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category); + + result.Items[0].Id.ShouldBe(newer.Id); + result.Items[1].Id.ShouldBe(older.Id); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_UnknownSortField_FallsBackToSubmissionDateDescending() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.WITHDRAWN); + + var older = await CreateApplicationAsync(formId, statusId, "REF-OLD", new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var newer = await CreateApplicationAsync(formId, statusId, "REF-NEW", new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: "notAField asc", category: category); + + result.Items[0].Id.ShouldBe(newer.Id); + result.Items[1].Id.ShouldBe(older.Id); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_StaticColumnFilters_AreCaseInsensitive() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var approvedStatusId = await CreateStatusAsync("Approved", GrantApplicationState.ON_HOLD); + var rejectedStatusId = await CreateStatusAsync("Rejected", GrantApplicationState.DEFER); + + var approved = await CreateApplicationAsync(formId, approvedStatusId, "REF-A", DateTime.UtcNow); + await CreateApplicationAsync(formId, rejectedStatusId, "REF-B", DateTime.UtcNow); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, + staticColumnFilters: [new ColumnFilterDto { Name = "status", Value = "APPROVED" }]); + + result.TotalCount.ShouldBe(1); + result.Items[0].Id.ShouldBe(approved.Id); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_EmptyDynamicColumnMatchIds_ShortCircuitsToZeroResults() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.GRANT_APPROVED); + await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, + dynamicColumnMatchIds: []); + + result.TotalCount.ShouldBe(0); + result.Items.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_GlobalFilter_OrsStaticFieldMatchWithPrecomputedWorksheetMatchIds() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.GRANT_NOT_APPROVED); + + // Matches via static ReferenceNo field + var staticMatch = await CreateApplicationAsync(formId, statusId, "ZEBRA-MATCH", DateTime.UtcNow); + // Doesn't match any static field, but is in the pre-computed worksheet match set + var worksheetMatch = await CreateApplicationAsync(formId, statusId, "REF-OTHER", DateTime.UtcNow); + // Matches neither + await CreateApplicationAsync(formId, statusId, "REF-EXCLUDED", DateTime.UtcNow); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, + filter: "ZEBRA", + globalDynamicMatchIds: [worksheetMatch.Id]); + + result.TotalCount.ShouldBe(2); + result.Items.ShouldContain(i => i.Id == staticMatch.Id); + result.Items.ShouldContain(i => i.Id == worksheetMatch.Id); + } +} From 543e49e90774495698d054f2972f24821c4efc08 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 19 Jun 2026 16:37:53 -0700 Subject: [PATCH 06/51] AB#33309 more improvements to onboarding --- .../IOnboardingApplicationProvider.cs | 2 + .../OnboardingApplicationRecord.cs | 2 + .../OnboardingRequestAppService.cs | 80 ++++-- .../Validation/SuperUsersValidationStep.cs | 36 ++- .../TenantManagement/Onboarding/Index.js | 63 ++++- .../OnboardingRequestAppServiceTests.cs | 178 ++++++++++-- .../OnboardingValidationStepsTests.cs | 45 +++ ...antManagerOnboardingApplicationProvider.cs | 198 ++++++++++--- .../OnboardingCoreFieldRegistry.cs | 68 +++++ ...nagerOnboardingApplicationProviderTests.cs | 264 +++++++++++++++++- 10 files changed, 838 insertions(+), 98 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs index dc2e7c1996..75fd8f7eb2 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs @@ -19,6 +19,8 @@ Task> GetPagedListAsync( IReadOnlyList? dynamicColumnMatchIds = null); Task GetByIdAsync(Guid id); Task> GetAllIdsAsync(string category); + Task> GetFormVersionIdsAsync(string category); + Task> GetMappedCoreFieldColumnsAsync(string category); Task> GetAvailableCategoriesAsync(); Task CloseApplicationAsync(Guid applicationId); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs index c115cb6558..6310942477 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; namespace Unity.TenantManagement; @@ -10,4 +11,5 @@ public class OnboardingApplicationRecord public DateTime SubmissionDate { get; set; } public string Status { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; + public Dictionary CoreFieldValues { get; set; } = []; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs index 3e607cb7c4..5c268dd20c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -8,6 +8,7 @@ using Unity.Flex.Worksheets; using Unity.Flex.Worksheets.Values; using Unity.Flex.WorksheetInstances; +using Unity.Modules.Shared.Correlation; using Unity.Modules.Shared.Permissions; using Unity.TenantManagement.Onboarding; using Unity.TenantManagement.Validation; @@ -53,11 +54,19 @@ public virtual async Task> GetListAsync(Onb var category = string.IsNullOrWhiteSpace(input.Category) ? DefaultCategory : input.Category; + // Core fields are real Application/Applicant columns — the provider can filter/sort + // them directly in SQL, same as the always-static columns. Only worksheet fields (JSONB) + // need the in-memory scan below. + var coreFieldKeys = (await ApplicationProvider.GetMappedCoreFieldColumnsAsync(category)) + .Select(c => c.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + bool IsProviderColumn(string name) => IsStaticColumn(name) || coreFieldKeys.Contains(name); + bool hasGlobalFilter = !string.IsNullOrWhiteSpace(input.Filter); - var staticColumnFilters = GetStaticColumnFilters(input.ColumnFilters); - var dynamicColumnFilters = GetDynamicColumnFilters(input.ColumnFilters); + var staticColumnFilters = GetStaticColumnFilters(input.ColumnFilters, IsProviderColumn); + var dynamicColumnFilters = GetDynamicColumnFilters(input.ColumnFilters, IsProviderColumn); var (sortField, sortDescending) = ParseSorting(input.Sorting); - bool isDynamicSort = sortField != null && !IsStaticColumn(sortField); + bool isDynamicSort = sortField != null && !IsProviderColumn(sortField); IReadOnlyList? globalDynamicMatchIds = null; IReadOnlyList? dynamicColumnMatchIds = null; @@ -125,8 +134,13 @@ public virtual async Task> GetListAsync(Onb } else { + // Reconstruct from the already-parsed (prefix-stripped) field rather than passing + // input.Sorting raw — core-field columns are sent by the client as "fields.", + // same as worksheet columns, and the provider only knows the bare field name. + var providerSorting = sortField != null ? $"{sortField} {(sortDescending ? "DESC" : "ASC")}" : null; + appPage = await ApplicationProvider.GetPagedListAsync( - input.SkipCount, input.MaxResultCount, input.Sorting, category, + input.SkipCount, input.MaxResultCount, providerSorting, category, hasGlobalFilter ? input.Filter : null, globalDynamicMatchIds, staticColumnFilters.Count > 0 ? staticColumnFilters : null, @@ -175,39 +189,48 @@ public virtual async Task GetColumnSchemaAsync(string return new OnboardingColumnSchemaDto { Columns = [] }; var resolvedCategory = string.IsNullOrWhiteSpace(category) ? DefaultCategory : category; - var onboardingAppIds = await ApplicationProvider.GetAllIdsAsync(resolvedCategory); + var formVersionIds = await ApplicationProvider.GetFormVersionIdsAsync(resolvedCategory); - if (onboardingAppIds.Count == 0) + if (formVersionIds.Count == 0) return new OnboardingColumnSchemaDto { Columns = [] }; - var worksheetIds = await WorksheetInstanceAppService - .GetDistinctWorksheetIdsByCorrelationIdsAsync(onboardingAppIds, ApplicationCorrelationProvider); - + var seenWorksheetIds = new HashSet(); var seenKeys = new HashSet(); var columns = new List(); - foreach (var worksheetId in worksheetIds) + foreach (var formVersionId in formVersionIds) { - WorksheetDto worksheet; - try { worksheet = await WorksheetAppService.GetAsync(worksheetId); } + List worksheets; + try { worksheets = await WorksheetAppService.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion); } catch { continue; } - foreach (var field in worksheet.Sections - .OrderBy(s => s.Order) - .SelectMany(s => s.Fields.OrderBy(f => f.Order)) - .Where(f => f.Enabled)) + foreach (var worksheet in worksheets) { - if (!seenKeys.Add($"{field.Key}|{field.Label}")) continue; - columns.Add(new OnboardingColumnDto + if (!seenWorksheetIds.Add(worksheet.Id)) continue; + + foreach (var field in worksheet.Sections + .OrderBy(s => s.Order) + .SelectMany(s => s.Fields.OrderBy(f => f.Order)) + .Where(f => f.Enabled)) { - Key = field.Key, - Label = field.Label, - Type = field.Type.ToString(), - Selected = true - }); + if (!seenKeys.Add($"{field.Key}|{field.Label}")) continue; + columns.Add(new OnboardingColumnDto + { + Key = field.Key, + Label = field.Label, + Type = field.Type.ToString(), + Selected = true + }); + } } } + foreach (var coreColumn in await ApplicationProvider.GetMappedCoreFieldColumnsAsync(resolvedCategory)) + { + if (!seenKeys.Add($"{coreColumn.Key}|{coreColumn.Label}")) continue; + columns.Add(coreColumn); + } + var saved = await ReadTenantMappingAsync(); saved.Columns = columns; return saved; @@ -377,14 +400,14 @@ private async Task ReadTenantMappingAsync() }; } - private static List GetStaticColumnFilters(List? filters) => + private static List GetStaticColumnFilters(List? filters, Func isProviderColumn) => (filters ?? []) - .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && IsStaticColumn(cf.Name)) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && isProviderColumn(cf.Name)) .ToList(); - private static List GetDynamicColumnFilters(List? filters) => + private static List GetDynamicColumnFilters(List? filters, Func isProviderColumn) => (filters ?? []) - .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && !IsStaticColumn(cf.Name)) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && !isProviderColumn(cf.Name)) .ToList(); private static bool IsStaticColumn(string name) => @@ -475,6 +498,9 @@ private static OnboardingRequestDto MapToDto( Category = app.Category }; + foreach (var (key, value) in app.CoreFieldValues) + dto.Fields[key] = value; + foreach (var instance in worksheetInstances) { if (string.IsNullOrWhiteSpace(instance.CurrentValue) || instance.CurrentValue == "{}") continue; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs index 2c0f8d29f4..ce8a3cf509 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs @@ -1,6 +1,9 @@ +#nullable enable using System; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; +using Unity.Flex.Worksheets.Values; using Volo.Abp; using Volo.Abp.DependencyInjection; @@ -31,6 +34,35 @@ public async Task ValidateAsync(OnboardingReques "None of the specified super user email addresses could be found in the directory."); } - internal static string[] ParseEmails(string superUsers) => - [.. superUsers.Split([',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(e => e.Contains('@'))]; + internal static string[] ParseEmails(string superUsers) + { + var dataGridEmails = ParseDataGridEmails(superUsers); + if (dataGridEmails.Length > 0) + return dataGridEmails; + + return [.. superUsers.Split([',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(e => e.Contains('@'))]; + } + + // Formio/CHEFS "Super Users" fields are submitted as a DataGrid: one row per super user, with + // columns such as name/email/title. The email column's key varies per worksheet (e.g. + // "s03_SuperUserEmail"), so it's matched by name rather than a fixed key. + private static string[] ParseDataGridEmails(string superUsers) + { + DataGridRowsValue? grid; + try + { + grid = JsonSerializer.Deserialize(superUsers); + } + catch (JsonException) + { + return []; + } + + if (grid?.Rows is not { Count: > 0 }) return []; + + return [.. grid.Rows + .Select(r => r.Cells.FirstOrDefault(c => c.Key.Contains("email", StringComparison.OrdinalIgnoreCase))?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v) && v.Contains('@')) + .Select(v => v!.Trim())]; + } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js index 9d7ba155b0..4979f8e133 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -5,10 +5,21 @@ let _selectedRow = null; let _selectedCategory = 'Onboarding'; + const CATEGORY_STORAGE_KEY = 'Onboarding_SelectedCategory'; + // ─── Fixed columns (always present) ────────────────────────────────────── const FIXED_COLUMNS = [ - { title: l('Onboarding:ColumnSubmissionNumber'), data: 'submissionNumber', name: 'submissionNumber', index: 0 }, + { + title: l('Onboarding:ColumnSubmissionNumber'), + data: 'submissionNumber', + name: 'submissionNumber', + index: 0, + render: function (data, type, row) { + if (type !== 'display') return data ?? ''; + return '' + _escapeHtml(data) + ''; + } + }, { title: l('Onboarding:ColumnStatus'), data: 'status', name: 'status', index: 1 }, { title: l('Onboarding:ColumnSubmissionDate'), @@ -65,6 +76,38 @@ }).join(''); } + // ─── Super Users DataGrid email extraction ─────────────────────────────── + + // Formio/CHEFS "Super Users" fields are submitted as a DataGrid: one row per super + // user, with columns such as name/email/title. The email column's key varies per + // worksheet (e.g. "s03_SuperUserEmail"), so it's matched by name rather than a fixed + // key — mirrors SuperUsersValidationStep.ParseEmails on the server. + // Returns null when `raw` isn't a DataGrid value at all (legacy plain-text field). + function _extractDataGridEmails(raw) { + if (!raw) return null; + let parsed; + try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; } + if (!parsed || !Array.isArray(parsed.rows)) return null; + + return parsed.rows + .map(function (row) { + const cell = (row.cells || []).find(function (c) { return /email/i.test(c.key || ''); }); + return cell ? String(cell.value || '').trim() : ''; + }) + .filter(function (v) { return v.includes('@'); }); + } + + function _renderSuperUsersPreview(data, type) { + const emails = _extractDataGridEmails(data); + if (emails === null) { + if (type === 'sort' || type === 'filter' || type === 'type') return data || ''; + return data ? _escapeHtml(data) : ''; + } + + if (type === 'sort' || type === 'filter' || type === 'type') return emails.join(', '); + return emails.length ? _escapeHtml(emails.join(', ')) : ''; + } + // ─── DataGrid cell renderer ─────────────────────────────────────────────── let _dataGridCache = {}; @@ -359,7 +402,7 @@ _triggerValidation(applicationId); }); $('#create-tenant-super-users-field').on('change', function () { - _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value'); + _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value', _renderSuperUsersPreview); _triggerValidation(applicationId); }); } @@ -393,7 +436,7 @@ _updateFieldPreview('create-tenant-program-area-field', 'create-tenant-program-area-value'); _updateFieldPreview('create-tenant-features-field', 'create-tenant-features-value', _renderCheckboxGroup); _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); - _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value'); + _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value', _renderSuperUsersPreview); $('#create-tenant-field-mapping').show(); _wireCreateTenantMappingHandlers(applicationId); _triggerValidation(applicationId); @@ -469,12 +512,12 @@ return jaro + prefix * 0.1 * (1 - jaro); } - const TENANT_NAME_CANONICALS = ['tenant name', 'organization name', 'company name', 'program name', 'applicant name']; + const TENANT_NAME_CANONICALS = ['tenant name', 'organization name', 'company name', 'program name', 'applicant name', 'tenant abbreviation']; const SUPER_USERS_CANONICALS = ['super user', 'super users', 'admin email', 'program manager', 'manager email', 'administrator', 'user email']; const MINISTRY_CANONICALS = ['ministry', 'ministry name', 'government ministry', 'responsible ministry']; const BRANCH_CANONICALS = ['branch', 'division branch', 'ministry branch', 'business branch']; const PROGRAM_AREA_CANONICALS = ['program area', 'program area name', 'program name', 'program']; - const FEATURES_CANONICALS = ['features', 'feature flags', 'program features', 'modules', 'enabled features']; + const FEATURES_CANONICALS = ['features', 'feature flags', 'program features', 'modules', 'enabled features', 'features to be enabled']; const MATCH_THRESHOLD = 0.85; function _bestMatch(fields, canonicals) { @@ -493,7 +536,8 @@ fields.forEach(function (f) { $sel.append(''); }); - const pick = savedKey || _bestMatch(fields, canonicals); + const savedKeyValid = savedKey && fields.some(function (f) { return f.key === savedKey; }); + const pick = (savedKeyValid ? savedKey : null) || _bestMatch(fields, canonicals); if (pick) $sel.val(pick); } @@ -516,11 +560,15 @@ url: abp.appPath + 'api/onboarding-requests/categories', type: 'GET' }).done(function (categories) { + const categoryList = categories || ['Onboarding']; const $sel = $('#onboarding-category-filter'); $sel.empty(); - (categories || ['Onboarding']).forEach(function (cat) { + categoryList.forEach(function (cat) { $sel.append(''); }); + + const savedCategory = localStorage.getItem(CATEGORY_STORAGE_KEY); + _selectedCategory = (savedCategory && categoryList.includes(savedCategory)) ? savedCategory : 'Onboarding'; $sel.val(_selectedCategory); }).always(function () { _loadSchemaAndInitTable(); @@ -528,6 +576,7 @@ $('#onboarding-category-filter').on('change', function () { _selectedCategory = $(this).val() || 'Onboarding'; + localStorage.setItem(CATEGORY_STORAGE_KEY, _selectedCategory); _loadSchemaAndInitTable(); }); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs index 477bc2524f..2025093b54 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs @@ -33,6 +33,8 @@ protected override void AfterAddApplication(IServiceCollection services) { _applicationProvider = Substitute.For(); _applicationProvider.GetAllIdsAsync(Arg.Any()).Returns(new List()); + _applicationProvider.GetFormVersionIdsAsync(Arg.Any()).Returns(new List()); + _applicationProvider.GetMappedCoreFieldColumnsAsync(Arg.Any()).Returns(new List()); _applicationProvider.GetPagedListAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>(), Arg.Any?>(), Arg.Any?>()) @@ -147,37 +149,173 @@ public async Task GetListAsync_DynamicColumnSort_StripsFieldsPrefixAndSortsInMem [Fact] public async Task GetColumnSchemaAsync_DedupesFieldsAcrossWorksheets_PreservesOrder() { - var appId = Guid.NewGuid(); + var formVersionId = Guid.NewGuid(); var ws1 = Guid.NewGuid(); var ws2 = Guid.NewGuid(); - _applicationProvider.GetAllIdsAsync("Onboarding").Returns(new List { appId }); - _worksheetInstanceAppService.GetDistinctWorksheetIdsByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider) - .Returns(new List { ws1, ws2 }); + _applicationProvider.GetFormVersionIdsAsync("Onboarding").Returns(new List { formVersionId }); - _worksheetAppService.GetAsync(ws1).Returns(new WorksheetDto + _worksheetAppService.GetListByCorrelationAsync(formVersionId, "FormVersion").Returns(new List { - Sections = [ - new WorksheetSectionDto { Order = 0, Fields = [ - new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, - new CustomFieldDto { Key = "ministry", Label = "Ministry", Order = 1, Enabled = true } - ]} - ] + new() { + Id = ws1, + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, + new CustomFieldDto { Key = "ministry", Label = "Ministry", Order = 1, Enabled = true } + ]} + ] + }, + new() { + Id = ws2, + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, // duplicate + new CustomFieldDto { Key = "hidden", Label = "Hidden", Order = 1, Enabled = false }, // disabled + new CustomFieldDto { Key = "email", Label = "Email", Order = 2, Enabled = true } + ]} + ] + } }); - _worksheetAppService.GetAsync(ws2).Returns(new WorksheetDto + + var result = await _appService.GetColumnSchemaAsync(); + + result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ministry", "email"]); + } + + [Fact] + public async Task GetColumnSchemaAsync_ReturnsColumns_WithNoApplicationsSubmittedYet() + { + var formVersionId = Guid.NewGuid(); + + _applicationProvider.GetFormVersionIdsAsync("Onboarding").Returns(new List { formVersionId }); + + _worksheetAppService.GetListByCorrelationAsync(formVersionId, "FormVersion").Returns(new List { - Sections = [ - new WorksheetSectionDto { Order = 0, Fields = [ - new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, // duplicate - new CustomFieldDto { Key = "hidden", Label = "Hidden", Order = 1, Enabled = false }, // disabled - new CustomFieldDto { Key = "email", Label = "Email", Order = 2, Enabled = true } - ]} - ] + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true } + ]} + ] + } }); var result = await _appService.GetColumnSchemaAsync(); - result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ministry", "email"]); + result.Columns!.Select(c => c.Key).ShouldBe(["branch"]); + } + + [Fact] + public async Task GetColumnSchemaAsync_AppendsMappedCoreFieldColumns_AfterWorksheetColumns() + { + var formVersionId = Guid.NewGuid(); + + _applicationProvider.GetFormVersionIdsAsync("Onboarding").Returns(new List { formVersionId }); + _worksheetAppService.GetListByCorrelationAsync(formVersionId, "FormVersion").Returns(new List + { + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true } + ]} + ] + } + }); + _applicationProvider.GetMappedCoreFieldColumnsAsync("Onboarding").Returns(new List + { + new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true }, + new() { Key = "branch", Label = "Branch", Type = "String", Selected = true } // duplicate key — should be deduped + }); + + var result = await _appService.GetColumnSchemaAsync(); + + result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ProjectName"]); + } + + [Fact] + public async Task GetListAsync_MergesCoreFieldValues_IntoFields() + { + var appId = Guid.NewGuid(); + + _applicationProvider.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), "Onboarding", Arg.Any(), + Arg.Any?>(), Arg.Any?>(), Arg.Any?>()) + .Returns(new PagedResultDto(1, [ + new OnboardingApplicationRecord + { + Id = appId, + Category = "Onboarding", + CoreFieldValues = new Dictionary { ["ProjectName"] = "Bridge Repair" } + } + ])); + + var result = await _appService.GetListAsync(new OnboardingListRequestDto()); + + result.Items[0].Fields["ProjectName"].ShouldBe("Bridge Repair"); + } + + [Fact] + public async Task GetListAsync_SortByMappedCoreField_PassesThroughToProvider_NoInMemoryFetchAll() + { + _applicationProvider.GetMappedCoreFieldColumnsAsync("Onboarding").Returns(new List + { + new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true } + }); + + (int Skip, int Take, string? Sorting)? captured = null; + _applicationProvider + .When(p => p.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any?>(), Arg.Any?>())) + .Do(call => captured = (call.ArgAt(0), call.ArgAt(1), call.ArgAt(2))); + + await _appService.GetListAsync(new OnboardingListRequestDto + { + Sorting = "fields.ProjectName asc", + SkipCount = 5, + MaxResultCount = 10 + }); + + // A core field is provider-handled: paging/sort go straight to SQL — no "fetch everything, + // sort in memory" fallback (which would show up as Skip=0, Take=int.MaxValue, Sorting=null). + captured.ShouldNotBeNull(); + captured!.Value.Skip.ShouldBe(5); + captured.Value.Take.ShouldBe(10); + captured.Value.Sorting.ShouldBe("ProjectName ASC"); + } + + [Fact] + public async Task GetListAsync_ColumnFilterOnMappedCoreField_RoutedAsStaticFilter_NotWorksheetScan() + { + _applicationProvider.GetMappedCoreFieldColumnsAsync("Onboarding").Returns(new List + { + new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true } + }); + + IReadOnlyList? capturedStaticFilters = null; + IReadOnlyList? capturedDynamicMatchIds = null; + _applicationProvider + .When(p => p.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any?>(), Arg.Any?>())) + .Do(call => + { + capturedStaticFilters = call.ArgAt?>(6); + capturedDynamicMatchIds = call.ArgAt?>(7); + }); + + await _appService.GetListAsync(new OnboardingListRequestDto + { + ColumnFilters = [new ColumnFilterDto { Name = "ProjectName", Value = "bridge" }] + }); + + capturedStaticFilters.ShouldNotBeNull(); + capturedStaticFilters!.ShouldContain(f => f.Name == "ProjectName" && f.Value == "bridge"); + // Worksheet match precomputation never ran for this filter. + capturedDynamicMatchIds.ShouldBeNull(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs index 33259adcd7..9f5ee81602 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs @@ -63,6 +63,51 @@ public void ParseEmails_HandlesDelimitersAndDropsNonEmailTokens(string input, st result.ShouldBe(expected); } + + [Fact] + public void ParseEmails_ExtractsEmailsFromFormioDataGridJson() + { + const string dataGridJson = """ + { + "rows": [ + { + "cells": [ + { "key": "s03_SuperUserName", "value": "Kingsley Shacklebolt" }, + { "key": "s03_SuperUserEmail", "value": "kingsley.shacklebolt@gov.bc.ca" }, + { "key": "s03_SuperUserTitle", "value": "Minister for Magic" } + ] + }, + { + "cells": [ + { "key": "s03_SuperUserName", "value": "Minerva McGonagall" }, + { "key": "s03_SuperUserEmail", "value": "m.mcgonagall@hogwarts.ac.uk" }, + { "key": "s03_SuperUserTitle", "value": "External Liaison Officer" } + ] + } + ] + } + """; + + var result = SuperUsersValidationStep.ParseEmails(dataGridJson); + + result.ShouldBe(["kingsley.shacklebolt@gov.bc.ca", "m.mcgonagall@hogwarts.ac.uk"]); + } + + [Fact] + public void ParseEmails_DataGridRowsWithoutEmailColumn_ReturnsEmpty() + { + const string dataGridJson = """ + { + "rows": [ + { "cells": [ { "key": "s03_SuperUserName", "value": "Kingsley Shacklebolt" } ] } + ] + } + """; + + var result = SuperUsersValidationStep.ParseEmails(dataGridJson); + + result.ShouldBeEmpty(); + } } public class TenantNameUniquenessStepTests diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs index fa6a3112be..5f3ac935a3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Dynamic.Core; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Unity.GrantManager.Applications; @@ -19,6 +20,7 @@ namespace Unity.GrantManager.TenantManagement; public class GrantManagerOnboardingApplicationProvider( IRepository applicationRepository, IRepository applicationFormRepository, + IRepository applicationFormVersionRepository, OnboardingApplicationManager onboardingApplicationManager) : IOnboardingApplicationProvider, ITransientDependency { @@ -28,6 +30,7 @@ private async Task> BuildQueryAsync(string category) .AsNoTracking() .Include(a => a.ApplicationForm) .Include(a => a.ApplicationStatus) + .Include(a => a.Applicant) .Where(a => a.ApplicationForm.Category == category); } @@ -52,12 +55,14 @@ public async Task> GetPagedListAsync query = query.Where(a => dynSet.Contains(a.Id)); } - // Global text search: static fields OR pre-computed worksheet-matched IDs + // Global text search: static fields OR pre-computed worksheet-matched IDs OR core-field matches if (!string.IsNullOrWhiteSpace(filter)) { - if (globalDynamicMatchIds is { Count: > 0 }) + var effectiveMatchIds = await MergeCoreFieldGlobalMatchesAsync(category, filter, globalDynamicMatchIds); + + if (effectiveMatchIds is { Count: > 0 }) { - var dynSet = globalDynamicMatchIds.ToHashSet(); + var dynSet = effectiveMatchIds.ToHashSet(); query = query.Where(a => a.ReferenceNo.Contains(filter) || a.ApplicationStatus.InternalStatus.Contains(filter) || @@ -92,6 +97,9 @@ public async Task> GetPagedListAsync case "category": query = query.Where(a => a.ApplicationForm.Category != null && a.ApplicationForm.Category.ToLower().Contains(loweredValue)); break; + default: + query = ApplyCoreFieldFilter(query, cf.Name, loweredValue); + break; } } #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons @@ -100,19 +108,14 @@ public async Task> GetPagedListAsync var totalCount = await query.CountAsync(); var ordered = ApplySorting(query, sorting); - var items = await ordered + var entities = await ordered .Skip(skipCount) .Take(maxResultCount) - .Select(a => new OnboardingApplicationRecord - { - Id = a.Id, - ReferenceNo = a.ReferenceNo, - SubmissionDate = a.SubmissionDate, - Status = a.ApplicationStatus.InternalStatus, - Category = a.ApplicationForm.Category ?? string.Empty - }) .ToListAsync(); + var coreFields = await GetMappedCoreFieldsAsync(category); + var items = entities.Select(a => ToRecord(a, coreFields)).ToList(); + return new PagedResultDto(totalCount, items); } @@ -121,27 +124,78 @@ public async Task> GetPagedListAsync var query = (await applicationRepository.GetQueryableAsync()) .AsNoTracking() .Include(a => a.ApplicationForm) - .Include(a => a.ApplicationStatus); + .Include(a => a.ApplicationStatus) + .Include(a => a.Applicant); - return await query - .Where(a => a.Id == id) - .Select(a => new OnboardingApplicationRecord - { - Id = a.Id, - ReferenceNo = a.ReferenceNo, - SubmissionDate = a.SubmissionDate, - Status = a.ApplicationStatus.InternalStatus, - Category = a.ApplicationForm.Category ?? string.Empty - }) - .FirstOrDefaultAsync(); + var entity = await query.FirstOrDefaultAsync(a => a.Id == id); + if (entity == null) return null; + + var coreFields = await GetMappedCoreFieldsAsync(entity.ApplicationForm.Category ?? string.Empty); + return ToRecord(entity, coreFields); } + private static OnboardingApplicationRecord ToRecord(Application a, IReadOnlyList coreFields) => + new() + { + Id = a.Id, + ReferenceNo = a.ReferenceNo, + SubmissionDate = a.SubmissionDate, + Status = a.ApplicationStatus.InternalStatus, + Category = a.ApplicationForm.Category ?? string.Empty, + CoreFieldValues = coreFields.ToDictionary(f => f.Key, f => f.Selector(a)) + }; + public async Task> GetAllIdsAsync(string category) { var query = await BuildQueryAsync(category); return await query.Select(a => a.Id).ToListAsync(); } + public async Task> GetFormVersionIdsAsync(string category) + { + var versions = await GetCurrentPublishedVersionsAsync(category); + return versions.Select(v => v.Id).ToList(); + } + + public async Task> GetMappedCoreFieldColumnsAsync(string category) + { + var coreFields = await GetMappedCoreFieldsAsync(category); + return coreFields + .Select(f => new OnboardingColumnDto { Key = f.Key, Label = f.Label, Type = f.Type, Selected = true }) + .ToList(); + } + + private async Task> GetMappedCoreFieldsAsync(string category) + { + var versions = await GetCurrentPublishedVersionsAsync(category); + if (versions.Count == 0) return []; + + return OnboardingCoreFieldRegistry.Fields + .Where(f => versions.Any(v => v.HasSubmissionHeaderMapping(f.Key))) + .ToList(); + } + + private async Task> GetCurrentPublishedVersionsAsync(string category) + { + var formIds = await (await applicationFormRepository.GetQueryableAsync()) + .AsNoTracking() + .Where(f => f.Category == category) + .Select(f => f.Id) + .ToListAsync(); + + if (formIds.Count == 0) return []; + + var versions = await (await applicationFormVersionRepository.GetQueryableAsync()) + .AsNoTracking() + .Where(v => formIds.Contains(v.ApplicationFormId) && v.Published) + .ToListAsync(); + + return versions + .GroupBy(v => v.ApplicationFormId) + .Select(g => g.OrderByDescending(v => v.Version).First()) + .ToList(); + } + public async Task CloseApplicationAsync(Guid applicationId) { await onboardingApplicationManager.TriggerAction(applicationId, GrantApplicationAction.Close); @@ -168,21 +222,87 @@ private static IQueryable ApplySorting(IQueryable quer var field = parts[0]; var desc = parts.Length > 1 && parts[1].Equals("DESC", StringComparison.OrdinalIgnoreCase); - return field.ToLowerInvariant() switch + switch (field.ToLowerInvariant()) { - "referenceno" or "submissionnumber" => desc - ? query.OrderByDescending(a => a.ReferenceNo) - : query.OrderBy(a => a.ReferenceNo), - "status" => desc - ? query.OrderByDescending(a => a.ApplicationStatus.InternalStatus) - : query.OrderBy(a => a.ApplicationStatus.InternalStatus), - "submissiondate" => desc - ? query.OrderByDescending(a => a.SubmissionDate) - : query.OrderBy(a => a.SubmissionDate), - "category" => desc - ? query.OrderByDescending(a => a.ApplicationForm.Category) - : query.OrderBy(a => a.ApplicationForm.Category), - _ => query.OrderByDescending(a => a.SubmissionDate) - }; + case "referenceno" or "submissionnumber": + return desc ? query.OrderByDescending(a => a.ReferenceNo) : query.OrderBy(a => a.ReferenceNo); + case "status": + return desc + ? query.OrderByDescending(a => a.ApplicationStatus.InternalStatus) + : query.OrderBy(a => a.ApplicationStatus.InternalStatus); + case "submissiondate": + return desc ? query.OrderByDescending(a => a.SubmissionDate) : query.OrderBy(a => a.SubmissionDate); + case "category": + return desc + ? query.OrderByDescending(a => a.ApplicationForm.Category) + : query.OrderBy(a => a.ApplicationForm.Category); + } + + // Not a static column — check the core-field registry and sort via its EF navigation + // path using dynamic LINQ (System.Linq.Dynamic.Core), same pattern as + // ApplicationRepository.MapSortingField for the main Applications list. + var coreField = OnboardingCoreFieldRegistry.Fields + .FirstOrDefault(f => f.Key.Equals(field, StringComparison.OrdinalIgnoreCase)); + if (coreField != null) + { + try + { + return query.OrderBy($"{coreField.EfPath} {(desc ? "DESC" : "ASC")}"); + } + catch + { + // Unsupported dynamic LINQ translation — fall through to the default sort. + } + } + + return query.OrderByDescending(a => a.SubmissionDate); + } + + private static IQueryable ApplyCoreFieldFilter(IQueryable query, string fieldName, string loweredValue) + { + var coreField = OnboardingCoreFieldRegistry.Fields + .FirstOrDefault(f => f.IsTextFilterable && f.Key.Equals(fieldName, StringComparison.OrdinalIgnoreCase)); + if (coreField == null) return query; + + try + { + return query.Where($"{coreField.EfPath} != null && {coreField.EfPath}.ToLower().Contains(@0)", loweredValue); + } + catch + { + // Unsupported dynamic LINQ translation — skip this filter rather than fail the whole request. + return query; + } + } + + private async Task?> MergeCoreFieldGlobalMatchesAsync( + string category, string filter, IReadOnlyList? existingMatchIds) + { + var coreMatches = await GetCoreFieldGlobalMatchIdsAsync(category, filter); + if (coreMatches.Count == 0) return existingMatchIds?.ToList(); + + var merged = new HashSet(coreMatches); + if (existingMatchIds != null) merged.UnionWith(existingMatchIds); + return merged.ToList(); + } + + private async Task> GetCoreFieldGlobalMatchIdsAsync(string category, string filter) + { + var textFields = (await GetMappedCoreFieldsAsync(category)).Where(f => f.IsTextFilterable).ToList(); + if (textFields.Count == 0) return []; + + var loweredFilter = filter.ToLowerInvariant(); + var predicate = string.Join(" || ", textFields.Select(f => $"({f.EfPath} != null && {f.EfPath}.ToLower().Contains(@0))")); + + try + { + var query = await BuildQueryAsync(category); + return await query.Where(predicate, loweredFilter).Select(a => a.Id).ToListAsync(); + } + catch + { + // Unsupported dynamic LINQ translation for one of the mapped fields — skip core-field matching. + return []; + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs new file mode 100644 index 0000000000..07a87290eb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs @@ -0,0 +1,68 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Unity.GrantManager.Applications; + +namespace Unity.GrantManager.TenantManagement; + +public sealed record CoreFieldDefinition(string Key, string Label, string Type, string EfPath, Func Selector) +{ + // Only string-shaped fields get server-side Contains filtering (matches how the existing + // static columns filter — ToLower().Contains()). Currency/Number/Date fields are still + // sortable, just not filterable yet — filtering on those would need a numeric/date-aware + // operator instead of substring match. + public bool IsTextFilterable => Type is "String" or "Email" or "Phone"; +} + +// Key/Label/Type mirror the [DisplayName]/[MapFieldType] attributes on +// Unity.GrantManager.Intakes.IntakeMapping. EfPath is the EF Core navigation path used for +// dynamic LINQ (System.Linq.Dynamic.Core) sorting/filtering — see +// GrantManagerOnboardingApplicationProvider.ApplySorting for the established pattern this +// mirrors (also used by ApplicationRepository.MapSortingField for the main Applications list). +// Only fields with a confirmed assignment in IntakeFormSubmissionManager.CreateNewApplicationAsync +// or ApplicantAppService.CreateOrRetrieveApplicantAsync/CreateApplicantAsync are listed here — +// several IntakeMapping fields (Contact*, Mailing*, most Physical*, OrgStatus, IndigenousOrgInd) +// are never persisted anywhere after intake and are intentionally omitted. +public static class OnboardingCoreFieldRegistry +{ + public static readonly IReadOnlyList Fields = + [ + new("ProjectName", "Project Name", "String", "ProjectName", a => a.ProjectName), + new("Acquisition", "Acquisition", "String", "Acquisition", a => a.Acquisition), + new("Forestry", "Forestry", "String", "Forestry", a => a.Forestry), + new("ForestryFocus", "Forestry Focus", "String", "ForestryFocus", a => a.ForestryFocus), + new("PhysicalCity", "Physical City", "String", "City", a => a.City), + new("EconomicRegion", "Economic Region", "String", "EconomicRegion", a => a.EconomicRegion), + new("CommunityPopulation", "Community Population", "Number", "CommunityPopulation", a => a.CommunityPopulation), + new("RequestedAmount", "Requested Amount", "Currency", "RequestedAmount", a => a.RequestedAmount), + new("ProjectStartDate", "Project Start Date", "Date", "ProjectStartDate", a => a.ProjectStartDate), + new("ProjectEndDate", "Project End Date", "Date", "ProjectEndDate", a => a.ProjectEndDate), + new("TotalProjectBudget", "Total Project Budget", "Currency", "TotalProjectBudget", a => a.TotalProjectBudget), + new("Community", "Community", "String", "Community", a => a.Community), + new("ElectoralDistrict", "Project Electoral District", "String", "ElectoralDistrict", a => a.ElectoralDistrict), + new("ApplicantElectoralDistrict", "Applicant Electoral District", "String", "ApplicantElectoralDistrict", a => a.ApplicantElectoralDistrict), + new("RegionalDistrict", "Regional District", "String", "RegionalDistrict", a => a.RegionalDistrict), + new("SigningAuthorityFullName", "Signing Authority Full Name", "String", "SigningAuthorityFullName", a => a.SigningAuthorityFullName), + new("SigningAuthorityTitle", "Signing Authority Title", "String", "SigningAuthorityTitle", a => a.SigningAuthorityTitle), + new("SigningAuthorityEmail", "Signing Authority Email", "Email", "SigningAuthorityEmail", a => a.SigningAuthorityEmail), + new("SigningAuthorityBusinessPhone", "Signing Authority Business Phone", "Phone", "SigningAuthorityBusinessPhone", a => a.SigningAuthorityBusinessPhone), + new("SigningAuthorityCellPhone", "Signing Authority Cell Phone", "Phone", "SigningAuthorityCellPhone", a => a.SigningAuthorityCellPhone), + new("Place", "Place", "String", "Place", a => a.Place), + new("RiskRanking", "Risk Ranking", "String", "RiskRanking", a => a.RiskRanking), + new("ProjectSummary", "Project Summary", "String", "ProjectSummary", a => a.ProjectSummary), + + // Applicant-backed — requires `.Include(a => a.Applicant)` on the query. + new("ApplicantName", "Applicant Name", "String", "Applicant.ApplicantName", a => a.Applicant.ApplicantName), + new("NonRegisteredBusinessName", "Non-Registered Organization Name", "String", "Applicant.NonRegOrgName", a => a.Applicant.NonRegOrgName), + new("OrgName", "Registered Organization Name", "String", "Applicant.OrgName", a => a.Applicant.OrgName), + new("OrgNumber", "Registered Organization Number", "String", "Applicant.OrgNumber", a => a.Applicant.OrgNumber), + new("BusinessNumber", "Business Number", "String", "Applicant.BusinessNumber", a => a.Applicant.BusinessNumber), + new("OrganizationType", "Organization Type", "String", "Applicant.OrganizationType", a => a.Applicant.OrganizationType), + new("Sector", "Sector", "String", "Applicant.Sector", a => a.Applicant.Sector), + new("SubSector", "Sub-Sector", "String", "Applicant.SubSector", a => a.Applicant.SubSector), + new("SectorSubSectorIndustryDesc", "Sub-Sector Industry Description", "String", "Applicant.SectorSubSectorIndustryDesc", a => a.Applicant.SectorSubSectorIndustryDesc), + new("ApproxNumberOfEmployees", "Approximate Number Of Employees", "Number", "Applicant.ApproxNumberOfEmployees", a => a.Applicant.ApproxNumberOfEmployees), + new("FiscalDay", "Fiscal Year End (FYE) Day", "Number", "Applicant.FiscalDay", a => a.Applicant.FiscalDay), + new("FiscalMonth", "Fiscal Year End (FYE) Month", "String", "Applicant.FiscalMonth", a => a.Applicant.FiscalMonth), + ]; +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs index 7f1d7f3af9..89608ec979 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Shouldly; using Unity.GrantManager.Applications; @@ -17,7 +18,9 @@ public class GrantManagerOnboardingApplicationProviderTests : GrantManagerApplic private readonly IOnboardingApplicationProvider _provider; private readonly IRepository _applicationRepository; private readonly IRepository _applicationFormRepository; + private readonly IRepository _applicationFormVersionRepository; private readonly IRepository _applicationStatusRepository; + private readonly IRepository _applicantRepository; private readonly IUnitOfWorkManager _unitOfWorkManager; public GrantManagerOnboardingApplicationProviderTests(ITestOutputHelper outputHelper) : base(outputHelper) @@ -25,7 +28,9 @@ public GrantManagerOnboardingApplicationProviderTests(ITestOutputHelper outputHe _provider = GetRequiredService(); _applicationRepository = GetRequiredService>(); _applicationFormRepository = GetRequiredService>(); + _applicationFormVersionRepository = GetRequiredService>(); _applicationStatusRepository = GetRequiredService>(); + _applicantRepository = GetRequiredService>(); _unitOfWorkManager = GetRequiredService(); } @@ -40,6 +45,16 @@ private async Task CreateFormAsync(string category) return form.Id; } + private async Task CreateFormVersionAsync( + Guid applicationFormId, int? version, bool published, string? submissionHeaderMapping = null) => + (await _applicationFormVersionRepository.InsertAsync(new ApplicationFormVersion + { + ApplicationFormId = applicationFormId, + Version = version, + Published = published, + SubmissionHeaderMapping = submissionHeaderMapping + }, autoSave: true)).Id; + private async Task CreateStatusAsync(string internalStatus, GrantApplicationState statusCode) => (await _applicationStatusRepository.InsertAsync(new ApplicationStatus { @@ -48,15 +63,24 @@ private async Task CreateStatusAsync(string internalStatus, GrantApplicati StatusCode = statusCode }, autoSave: true)).Id; + private async Task CreateApplicantAsync(string applicantName) => + (await _applicantRepository.InsertAsync(new Applicant + { + ApplicantName = applicantName + }, autoSave: true)).Id; + private async Task CreateApplicationAsync( - Guid applicationFormId, Guid applicationStatusId, string referenceNo, DateTime submissionDate) => + Guid applicationFormId, Guid applicationStatusId, string referenceNo, DateTime submissionDate, + Guid? applicantId = null, string? projectName = null, decimal? requestedAmount = null) => await _applicationRepository.InsertAsync(new Application { ApplicationFormId = applicationFormId, ApplicationStatusId = applicationStatusId, - ApplicantId = GrantManagerTestData.Applicant1_Id, + ApplicantId = applicantId ?? GrantManagerTestData.Applicant1_Id, ReferenceNo = referenceNo, - SubmissionDate = submissionDate + SubmissionDate = submissionDate, + ProjectName = projectName ?? string.Empty, + RequestedAmount = requestedAmount ?? 0 }, autoSave: true); [Fact] @@ -156,4 +180,238 @@ public async Task GetPagedListAsync_GlobalFilter_OrsStaticFieldMatchWithPrecompu result.Items.ShouldContain(i => i.Id == staticMatch.Id); result.Items.ShouldContain(i => i.Id == worksheetMatch.Id); } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetFormVersionIdsAsync_PicksHighestPublishedVersion_PerForm() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true); + var latestPublished = await CreateFormVersionAsync(formId, version: 2, published: true); + await CreateFormVersionAsync(formId, version: 3, published: false); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetFormVersionIdsAsync(category); + + result.ShouldBe([latestPublished]); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetFormVersionIdsAsync_FormWithNoPublishedVersions_IsExcluded() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: false); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetFormVersionIdsAsync(category); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetFormVersionIdsAsync_UnknownCategory_ReturnsEmpty() + { + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetFormVersionIdsAsync($"OnboardingTest-{Guid.NewGuid()}"); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetMappedCoreFieldColumnsAsync_ReturnsOnlyFieldsWithASubmissionHeaderMapping() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true, + submissionHeaderMapping: """{"ProjectName": "projectName", "RequestedAmount": "requestedAmount"}"""); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetMappedCoreFieldColumnsAsync(category); + + result.Select(c => c.Key).ShouldBe(["ProjectName", "RequestedAmount"], ignoreOrder: true); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetMappedCoreFieldColumnsAsync_FieldNotInRegistry_IsExcluded() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true, + submissionHeaderMapping: """{"ContactEmail": "contactEmail"}"""); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetMappedCoreFieldColumnsAsync(category); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAndById_PopulateCoreFieldValues_ForMappedFields() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true, + submissionHeaderMapping: """{"ProjectName": "projectName"}"""); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + var applicantId = await CreateApplicantAsync("Test Applicant"); + + var application = await CreateApplicationAsync(formId, statusId, "REF-CORE", DateTime.UtcNow, + applicantId: applicantId, projectName: "Bridge Repair", requestedAmount: 5000m); + + using var uow = _unitOfWorkManager.Begin(); + + var paged = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category); + paged.Items[0].CoreFieldValues.ShouldContainKey("ProjectName"); + paged.Items[0].CoreFieldValues["ProjectName"].ShouldBe("Bridge Repair"); + paged.Items[0].CoreFieldValues.ShouldNotContainKey("RequestedAmount"); // not mapped for this form + + var byId = await _provider.GetByIdAsync(application.Id); + byId!.CoreFieldValues["ProjectName"].ShouldBe("Bridge Repair"); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_SortsByCoreField() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + + var bravo = await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow, projectName: "Bravo Project"); + var alpha = await CreateApplicationAsync(formId, statusId, "REF-B", DateTime.UtcNow, projectName: "Alpha Project"); + + using var uow = _unitOfWorkManager.Begin(); + + var ascending = await _provider.GetPagedListAsync(0, 10, sorting: "ProjectName ASC", category: category); + ascending.Items[0].Id.ShouldBe(alpha.Id); + ascending.Items[1].Id.ShouldBe(bravo.Id); + + var descending = await _provider.GetPagedListAsync(0, 10, sorting: "ProjectName DESC", category: category); + descending.Items[0].Id.ShouldBe(bravo.Id); + descending.Items[1].Id.ShouldBe(alpha.Id); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_SortsByApplicantBackedCoreField() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + + var zooApplicantId = await CreateApplicantAsync("Zoo Org"); + var acmeApplicantId = await CreateApplicantAsync("Acme Org"); + + var zoo = await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow, applicantId: zooApplicantId); + var acme = await CreateApplicationAsync(formId, statusId, "REF-B", DateTime.UtcNow, applicantId: acmeApplicantId); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: "ApplicantName ASC", category: category); + + result.Items[0].Id.ShouldBe(acme.Id); + result.Items[1].Id.ShouldBe(zoo.Id); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_FiltersByCoreFieldContains() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + + var matching = await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow, projectName: "Bridge Repair"); + await CreateApplicationAsync(formId, statusId, "REF-B", DateTime.UtcNow, projectName: "Road Resurfacing"); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, + staticColumnFilters: [new ColumnFilterDto { Name = "ProjectName", Value = "bridge" }]); + + result.TotalCount.ShouldBe(1); + result.Items[0].Id.ShouldBe(matching.Id); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_FiltersByNonTextCoreField_HasNoEffect() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow, requestedAmount: 5000m); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, + staticColumnFilters: [new ColumnFilterDto { Name = "RequestedAmount", Value = "5000" }]); + + // Currency/Number fields aren't text-filterable yet — the filter is a no-op rather than an error. + result.TotalCount.ShouldBe(1); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_GlobalFilter_MatchesMappedCoreFieldValue() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true, + submissionHeaderMapping: """{"ProjectName": "projectName"}"""); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + + var matching = await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow, projectName: "Bridge Repair"); + await CreateApplicationAsync(formId, statusId, "REF-B", DateTime.UtcNow, projectName: "Road Resurfacing"); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, filter: "bridge"); + + result.TotalCount.ShouldBe(1); + result.Items[0].Id.ShouldBe(matching.Id); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_GlobalFilter_UnmappedCoreField_DoesNotMatch() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true); // no SubmissionHeaderMapping + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow, projectName: "Bridge Repair"); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, filter: "bridge"); + + result.TotalCount.ShouldBe(0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAsync_GlobalFilter_CombinesCoreFieldMatchesWithPrecomputedWorksheetMatchIds() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true, + submissionHeaderMapping: """{"ProjectName": "projectName"}"""); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + + var coreMatch = await CreateApplicationAsync(formId, statusId, "REF-A", DateTime.UtcNow, projectName: "Bridge Repair"); + var worksheetMatch = await CreateApplicationAsync(formId, statusId, "REF-B", DateTime.UtcNow); + await CreateApplicationAsync(formId, statusId, "REF-C", DateTime.UtcNow); // matches neither + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category, + filter: "bridge", + globalDynamicMatchIds: [worksheetMatch.Id]); + + result.TotalCount.ShouldBe(2); + result.Items.ShouldContain(i => i.Id == coreMatch.Id); + result.Items.ShouldContain(i => i.Id == worksheetMatch.Id); + } } From f0cc6b1adb121a0124deb4bb1ef89f9fe25e346d Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 22 Jun 2026 10:24:40 -0700 Subject: [PATCH 07/51] AB#33309 multiversion dynamic columns --- .../OnboardingRequestAppService.cs | 9 +- .../Validation/SuperUsersValidationStep.cs | 3 +- .../Onboarding/OnboardingFeatureMapTests.cs | 4 +- .../OnboardingRequestAppServiceTests.cs | 82 +++++++++++++---- .../OnboardingValidationStepsTests.cs | 10 +- ...antManagerOnboardingApplicationProvider.cs | 26 +++--- .../OnboardingCoreFieldRegistry.cs | 14 ++- ...nagerOnboardingApplicationProviderTests.cs | 92 +++++++++++++++++-- 8 files changed, 186 insertions(+), 54 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs index 5c268dd20c..7462c56c6d 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -195,7 +195,10 @@ public virtual async Task GetColumnSchemaAsync(string return new OnboardingColumnSchemaDto { Columns = [] }; var seenWorksheetIds = new HashSet(); - var seenKeys = new HashSet(); + // Columns are combined across form versions by key alone — two versions mapping the + // same key are treated as the same column even if the label/worksheet differs between + // them. Whichever version is encountered first wins the displayed label/type. + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); var columns = new List(); foreach (var formVersionId in formVersionIds) @@ -213,7 +216,7 @@ public virtual async Task GetColumnSchemaAsync(string .SelectMany(s => s.Fields.OrderBy(f => f.Order)) .Where(f => f.Enabled)) { - if (!seenKeys.Add($"{field.Key}|{field.Label}")) continue; + if (!seenKeys.Add(field.Key)) continue; columns.Add(new OnboardingColumnDto { Key = field.Key, @@ -227,7 +230,7 @@ public virtual async Task GetColumnSchemaAsync(string foreach (var coreColumn in await ApplicationProvider.GetMappedCoreFieldColumnsAsync(resolvedCategory)) { - if (!seenKeys.Add($"{coreColumn.Key}|{coreColumn.Label}")) continue; + if (!seenKeys.Add(coreColumn.Key)) continue; columns.Add(coreColumn); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs index ce8a3cf509..7db1572c3c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs @@ -1,4 +1,3 @@ -#nullable enable using System; using System.Linq; using System.Text.Json; @@ -48,7 +47,7 @@ internal static string[] ParseEmails(string superUsers) // "s03_SuperUserEmail"), so it's matched by name rather than a fixed key. private static string[] ParseDataGridEmails(string superUsers) { - DataGridRowsValue? grid; + DataGridRowsValue grid; try { grid = JsonSerializer.Deserialize(superUsers); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs index d0e186bb17..e5e7e990a6 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs @@ -9,9 +9,9 @@ public class OnboardingFeatureMapTests [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ResolveFeatureKeys_BlankInput_ReturnsEmpty(string? input) + public void ResolveFeatureKeys_BlankInput_ReturnsEmpty(string input) { - var result = OnboardingFeatureMap.ResolveFeatureKeys(input!); + var result = OnboardingFeatureMap.ResolveFeatureKeys(input); result.ShouldBeEmpty(); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs index 2025093b54..7667f51dfa 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs @@ -36,8 +36,8 @@ protected override void AfterAddApplication(IServiceCollection services) _applicationProvider.GetFormVersionIdsAsync(Arg.Any()).Returns(new List()); _applicationProvider.GetMappedCoreFieldColumnsAsync(Arg.Any()).Returns(new List()); _applicationProvider.GetPagedListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any?>(), Arg.Any?>()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>()) .Returns(new PagedResultDto(0, [])); _worksheetInstanceAppService = Substitute.For(); @@ -50,7 +50,7 @@ protected override void AfterAddApplication(IServiceCollection services) _settingManager = Substitute.For(); _settingManager.GetOrNullAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((string?)null); + .Returns((string)null); services.AddSingleton(_applicationProvider); services.AddSingleton(_worksheetInstanceAppService); @@ -93,12 +93,12 @@ public async Task GetListAsync_DynamicColumnFilters_AreCaseInsensitiveAndAndComb WorksheetInstanceFor(wrongBranch, ("ministry", "Health"), ("branch", "South")) }); - IReadOnlyList? capturedMatchIds = null; + IReadOnlyList capturedMatchIds = null; _applicationProvider .When(p => p.GetPagedListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any?>(), Arg.Any?>())) - .Do(call => capturedMatchIds = call.ArgAt?>(7)); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>())) + .Do(call => capturedMatchIds = call.ArgAt>(7)); await _appService.GetListAsync(new OnboardingListRequestDto { @@ -207,6 +207,48 @@ public async Task GetColumnSchemaAsync_ReturnsColumns_WithNoApplicationsSubmitte result.Columns!.Select(c => c.Key).ShouldBe(["branch"]); } + [Fact] + public async Task GetColumnSchemaAsync_CombinesFieldsAcrossFormVersions_ByKeyEvenWithDifferentLabel() + { + var oldFormVersionId = Guid.NewGuid(); + var newFormVersionId = Guid.NewGuid(); + + // Both versions are published and mapped — GetFormVersionIdsAsync now returns every + // published version, not just the latest, so both contribute columns here. + _applicationProvider.GetFormVersionIdsAsync("Onboarding") + .Returns(new List { oldFormVersionId, newFormVersionId }); + + _worksheetAppService.GetListByCorrelationAsync(oldFormVersionId, "FormVersion").Returns(new List + { + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch (old wording)", Order = 0, Enabled = true } + ]} + ] + } + }); + _worksheetAppService.GetListByCorrelationAsync(newFormVersionId, "FormVersion").Returns(new List + { + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch (new wording)", Order = 0, Enabled = true }, + new CustomFieldDto { Key = "ministry", Label = "Ministry", Order = 1, Enabled = true } + ]} + ] + } + }); + + var result = await _appService.GetColumnSchemaAsync(); + + // Same key across versions collapses to one column — the first version encountered wins the label. + result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ministry"]); + result.Columns!.First(c => c.Key == "branch").Label.ShouldBe("Branch (old wording)"); + } + [Fact] public async Task GetColumnSchemaAsync_AppendsMappedCoreFieldColumns_AfterWorksheetColumns() { @@ -241,14 +283,14 @@ public async Task GetListAsync_MergesCoreFieldValues_IntoFields() var appId = Guid.NewGuid(); _applicationProvider.GetPagedListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), "Onboarding", Arg.Any(), - Arg.Any?>(), Arg.Any?>(), Arg.Any?>()) + Arg.Any(), Arg.Any(), Arg.Any(), "Onboarding", Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>()) .Returns(new PagedResultDto(1, [ new OnboardingApplicationRecord { Id = appId, Category = "Onboarding", - CoreFieldValues = new Dictionary { ["ProjectName"] = "Bridge Repair" } + CoreFieldValues = new Dictionary { ["ProjectName"] = "Bridge Repair" } } ])); @@ -265,12 +307,12 @@ public async Task GetListAsync_SortByMappedCoreField_PassesThroughToProvider_NoI new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true } }); - (int Skip, int Take, string? Sorting)? captured = null; + (int Skip, int Take, string Sorting)? captured = null; _applicationProvider .When(p => p.GetPagedListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any?>(), Arg.Any?>())) - .Do(call => captured = (call.ArgAt(0), call.ArgAt(1), call.ArgAt(2))); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>())) + .Do(call => captured = (call.ArgAt(0), call.ArgAt(1), call.ArgAt(2))); await _appService.GetListAsync(new OnboardingListRequestDto { @@ -295,16 +337,16 @@ public async Task GetListAsync_ColumnFilterOnMappedCoreField_RoutedAsStaticFilte new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true } }); - IReadOnlyList? capturedStaticFilters = null; - IReadOnlyList? capturedDynamicMatchIds = null; + IReadOnlyList capturedStaticFilters = null; + IReadOnlyList capturedDynamicMatchIds = null; _applicationProvider .When(p => p.GetPagedListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any?>(), Arg.Any?>())) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>())) .Do(call => { - capturedStaticFilters = call.ArgAt?>(6); - capturedDynamicMatchIds = call.ArgAt?>(7); + capturedStaticFilters = call.ArgAt>(6); + capturedDynamicMatchIds = call.ArgAt>(7); }); await _appService.GetListAsync(new OnboardingListRequestDto diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs index 9f5ee81602..972c606df6 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs @@ -30,7 +30,7 @@ public async Task ValidateAsync_NoEmailsParsed_ReturnsFailure() public async Task ValidateAsync_AnyParsedEmailResolves_ReturnsSuccess() { var lookup = Substitute.For(); - lookup.FindUserGuidByEmailAsync("first@example.com").Returns((string?)null); + lookup.FindUserGuidByEmailAsync("first@example.com").Returns((string)null); lookup.FindUserGuidByEmailAsync("second@example.com").Returns("guid-123"); var step = new SuperUsersValidationStep(lookup); @@ -43,7 +43,7 @@ public async Task ValidateAsync_AnyParsedEmailResolves_ReturnsSuccess() public async Task ValidateAsync_NoneOfTheParsedEmailsResolve_ReturnsFailure() { var lookup = Substitute.For(); - lookup.FindUserGuidByEmailAsync(Arg.Any()).Returns((string?)null); + lookup.FindUserGuidByEmailAsync(Arg.Any()).Returns((string)null); var step = new SuperUsersValidationStep(lookup); var result = await step.ValidateAsync(RequestWithSuperUsers("first@example.com,second@example.com")); @@ -112,8 +112,8 @@ public void ParseEmails_DataGridRowsWithoutEmailColumn_ReturnsEmpty() public class TenantNameUniquenessStepTests { - private static OnboardingRequestDto RequestWithTenantName(string? tenantName) => - new() { TenantName = tenantName ?? string.Empty }; + private static OnboardingRequestDto RequestWithTenantName(string tenantName) => + new() { TenantName = tenantName }; // Tenant's (Guid, string, string) constructor is internal to the ABP assembly; reflection is the // only way to build a real instance here, since this test only needs a non-null "found" result. @@ -154,7 +154,7 @@ public async Task ValidateAsync_NameIsUnique_ReturnsSuccess() { var tenantRepository = Substitute.For(); tenantRepository.FindByNameAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((Tenant?)null); + .Returns((Tenant)null); var step = new TenantNameUniquenessStep(tenantRepository); var result = await step.ValidateAsync(RequestWithTenantName("Brand New Co")); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs index 5f3ac935a3..740239b020 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs @@ -31,6 +31,7 @@ private async Task> BuildQueryAsync(string category) .Include(a => a.ApplicationForm) .Include(a => a.ApplicationStatus) .Include(a => a.Applicant) + .Include(a => a.ApplicantAgent) .Where(a => a.ApplicationForm.Category == category); } @@ -125,7 +126,8 @@ public async Task> GetPagedListAsync .AsNoTracking() .Include(a => a.ApplicationForm) .Include(a => a.ApplicationStatus) - .Include(a => a.Applicant); + .Include(a => a.Applicant) + .Include(a => a.ApplicantAgent); var entity = await query.FirstOrDefaultAsync(a => a.Id == id); if (entity == null) return null; @@ -153,7 +155,7 @@ public async Task> GetAllIdsAsync(string category) public async Task> GetFormVersionIdsAsync(string category) { - var versions = await GetCurrentPublishedVersionsAsync(category); + var versions = await GetFormVersionsAsync(category); return versions.Select(v => v.Id).ToList(); } @@ -167,7 +169,7 @@ public async Task> GetMappedCoreFieldColumnsAsync(stri private async Task> GetMappedCoreFieldsAsync(string category) { - var versions = await GetCurrentPublishedVersionsAsync(category); + var versions = await GetFormVersionsAsync(category); if (versions.Count == 0) return []; return OnboardingCoreFieldRegistry.Fields @@ -175,7 +177,14 @@ private async Task> GetMappedCoreFieldsAsync(string ca .ToList(); } - private async Task> GetCurrentPublishedVersionsAsync(string category) + // Returns every version across every form in the category — deliberately not filtered to + // Published==true. CHEFS sync auto-unpublishes a form's previous version the moment a new + // one is published (see ApplicationFormVersionAppService.UnPublishFormVersions), so at most + // one version per form is ever Published at a given time. Filtering on that flag would + // silently drop every prior version's field mappings/worksheet links as soon as a newer + // version goes live. A field mapped on any version (old or new) should still surface its + // column; columns are combined across versions by key (see GetColumnSchemaAsync). + private async Task> GetFormVersionsAsync(string category) { var formIds = await (await applicationFormRepository.GetQueryableAsync()) .AsNoTracking() @@ -185,15 +194,10 @@ private async Task> GetCurrentPublishedVersionsAsyn if (formIds.Count == 0) return []; - var versions = await (await applicationFormVersionRepository.GetQueryableAsync()) + return await (await applicationFormVersionRepository.GetQueryableAsync()) .AsNoTracking() - .Where(v => formIds.Contains(v.ApplicationFormId) && v.Published) + .Where(v => formIds.Contains(v.ApplicationFormId)) .ToListAsync(); - - return versions - .GroupBy(v => v.ApplicationFormId) - .Select(g => g.OrderByDescending(v => v.Version).First()) - .ToList(); } public async Task CloseApplicationAsync(Guid applicationId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs index 07a87290eb..4a27902cc8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs @@ -21,8 +21,11 @@ public sealed record CoreFieldDefinition(string Key, string Label, string Type, // mirrors (also used by ApplicationRepository.MapSortingField for the main Applications list). // Only fields with a confirmed assignment in IntakeFormSubmissionManager.CreateNewApplicationAsync // or ApplicantAppService.CreateOrRetrieveApplicantAsync/CreateApplicantAsync are listed here — -// several IntakeMapping fields (Contact*, Mailing*, most Physical*, OrgStatus, IndigenousOrgInd) -// are never persisted anywhere after intake and are intentionally omitted. +// several IntakeMapping fields (Mailing*, most Physical*, OrgStatus, IndigenousOrgInd) are never +// persisted anywhere after intake and are intentionally omitted. Contact* fields ARE persisted, +// to the nullable Application.ApplicantAgent navigation (not Applicant) — see the ApplicantAgent- +// backed entries below; querying them requires a `.Include(a => a.ApplicantAgent)`, added in +// GrantManagerOnboardingApplicationProvider.BuildQueryAsync/GetByIdAsync. public static class OnboardingCoreFieldRegistry { public static readonly IReadOnlyList Fields = @@ -64,5 +67,12 @@ public static class OnboardingCoreFieldRegistry new("ApproxNumberOfEmployees", "Approximate Number Of Employees", "Number", "Applicant.ApproxNumberOfEmployees", a => a.Applicant.ApproxNumberOfEmployees), new("FiscalDay", "Fiscal Year End (FYE) Day", "Number", "Applicant.FiscalDay", a => a.Applicant.FiscalDay), new("FiscalMonth", "Fiscal Year End (FYE) Month", "String", "Applicant.FiscalMonth", a => a.Applicant.FiscalMonth), + + // ApplicantAgent-backed — nullable navigation, requires `.Include(a => a.ApplicantAgent)`. + new("ContactFullName", "Contact Full Name", "String", "ApplicantAgent.Name", a => a.ApplicantAgent?.Name), + new("ContactTitle", "Contact Title", "String", "ApplicantAgent.Title", a => a.ApplicantAgent?.Title), + new("ContactEmail", "Contact Email", "Email", "ApplicantAgent.Email", a => a.ApplicantAgent?.Email), + new("ContactBusinessPhone", "Contact Business Phone", "Phone", "ApplicantAgent.Phone", a => a.ApplicantAgent?.Phone), + new("ContactCellPhone", "Contact Cell Phone", "Phone", "ApplicantAgent.Phone2", a => a.ApplicantAgent?.Phone2), ]; } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs index 89608ec979..0ea0adf18a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs @@ -183,27 +183,29 @@ public async Task GetPagedListAsync_GlobalFilter_OrsStaticFieldMatchWithPrecompu [Fact] [Trait("Category", "Integration")] - public async Task GetFormVersionIdsAsync_PicksHighestPublishedVersion_PerForm() + public async Task GetFormVersionIdsAsync_IncludesEveryVersion_PerForm_RegardlessOfPublishedFlag() { + // CHEFS sync auto-unpublishes a form's previous version the moment a new one is + // published, so in practice only one version is ever Published==true at a time. Column + // discovery must not filter on that flag or it would silently drop every prior version's + // mappings — see GetFormVersionsAsync. var category = $"OnboardingTest-{Guid.NewGuid()}"; var formId = await CreateFormAsync(category); - await CreateFormVersionAsync(formId, version: 1, published: true); - var latestPublished = await CreateFormVersionAsync(formId, version: 2, published: true); - await CreateFormVersionAsync(formId, version: 3, published: false); + var oldUnpublished = await CreateFormVersionAsync(formId, version: 1, published: false); + var currentPublished = await CreateFormVersionAsync(formId, version: 2, published: true); using var uow = _unitOfWorkManager.Begin(); var result = await _provider.GetFormVersionIdsAsync(category); - result.ShouldBe([latestPublished]); + result.ShouldBe([oldUnpublished, currentPublished], ignoreOrder: true); } [Fact] [Trait("Category", "Integration")] - public async Task GetFormVersionIdsAsync_FormWithNoPublishedVersions_IsExcluded() + public async Task GetFormVersionIdsAsync_FormWithNoVersions_IsExcluded() { var category = $"OnboardingTest-{Guid.NewGuid()}"; - var formId = await CreateFormAsync(category); - await CreateFormVersionAsync(formId, version: 1, published: false); + await CreateFormAsync(category); using var uow = _unitOfWorkManager.Begin(); var result = await _provider.GetFormVersionIdsAsync(category); @@ -236,14 +238,36 @@ await CreateFormVersionAsync(formId, version: 1, published: true, result.Select(c => c.Key).ShouldBe(["ProjectName", "RequestedAmount"], ignoreOrder: true); } + [Fact] + [Trait("Category", "Integration")] + public async Task GetMappedCoreFieldColumnsAsync_CombinesMappingsAcrossAllVersions() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + // The older version got auto-unpublished when v2 went live (CHEFS sync behavior), and + // it maps a different core field than the current published version. Both should still + // surface — columns are combined by key across every version, published or not. + await CreateFormVersionAsync(formId, version: 1, published: false, + submissionHeaderMapping: """{"ProjectName": "projectName"}"""); + await CreateFormVersionAsync(formId, version: 2, published: true, + submissionHeaderMapping: """{"RequestedAmount": "requestedAmount"}"""); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetMappedCoreFieldColumnsAsync(category); + + result.Select(c => c.Key).ShouldBe(["ProjectName", "RequestedAmount"], ignoreOrder: true); + } + [Fact] [Trait("Category", "Integration")] public async Task GetMappedCoreFieldColumnsAsync_FieldNotInRegistry_IsExcluded() { var category = $"OnboardingTest-{Guid.NewGuid()}"; var formId = await CreateFormAsync(category); + // TotalScore is grant-assessment workflow state, intentionally never added to the + // onboarding core-field registry — see OnboardingCoreFieldRegistry header comment. await CreateFormVersionAsync(formId, version: 1, published: true, - submissionHeaderMapping: """{"ContactEmail": "contactEmail"}"""); + submissionHeaderMapping: """{"TotalScore": "totalScore"}"""); using var uow = _unitOfWorkManager.Begin(); var result = await _provider.GetMappedCoreFieldColumnsAsync(category); @@ -251,6 +275,56 @@ await CreateFormVersionAsync(formId, version: 1, published: true, result.ShouldBeEmpty(); } + [Fact] + [Trait("Category", "Integration")] + public async Task GetMappedCoreFieldColumnsAsync_IncludesApplicantAgentBackedContactEmail() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true, + submissionHeaderMapping: """{"ContactEmail": "contactEmail"}"""); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetMappedCoreFieldColumnsAsync(category); + + result.Select(c => c.Key).ShouldBe(["ContactEmail"]); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPagedListAndById_PopulateContactEmail_FromApplicantAgent() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + var formId = await CreateFormAsync(category); + await CreateFormVersionAsync(formId, version: 1, published: true, + submissionHeaderMapping: """{"ContactEmail": "contactEmail"}"""); + var statusId = await CreateStatusAsync("Submitted", GrantApplicationState.ASSIGNED); + + var withAgent = await CreateApplicationAsync(formId, statusId, "REF-AGENT", DateTime.UtcNow); + var applicantAgentRepository = GetRequiredService>(); + await applicantAgentRepository.InsertAsync(new ApplicantAgent + { + ApplicantId = withAgent.ApplicantId, + ApplicationId = withAgent.Id, + Name = "Jane Contact", + Email = "jane.contact@example.com" + }, autoSave: true); + + var withoutAgent = await CreateApplicationAsync(formId, statusId, "REF-NO-AGENT", DateTime.UtcNow); + + using var uow = _unitOfWorkManager.Begin(); + + var paged = await _provider.GetPagedListAsync(0, 10, sorting: null, category: category); + paged.Items.First(i => i.Id == withAgent.Id).CoreFieldValues["ContactEmail"].ShouldBe("jane.contact@example.com"); + paged.Items.First(i => i.Id == withoutAgent.Id).CoreFieldValues["ContactEmail"].ShouldBeNull(); + + var byId = await _provider.GetByIdAsync(withAgent.Id); + byId!.CoreFieldValues["ContactEmail"].ShouldBe("jane.contact@example.com"); + + var byIdNoAgent = await _provider.GetByIdAsync(withoutAgent.Id); + byIdNoAgent!.CoreFieldValues["ContactEmail"].ShouldBeNull(); + } + [Fact] [Trait("Category", "Integration")] public async Task GetPagedListAndById_PopulateCoreFieldValues_ForMappedFields() From f74ed2f59c67fd4accc80472953a18879ce0d72f Mon Sep 17 00:00:00 2001 From: Andre Goncalves <98196495+AndreGAot@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:42:42 -0700 Subject: [PATCH 08/51] Potential fix for pull request finding 'CodeQL / DOM text reinterpreted as HTML' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Pages/TenantManagement/Onboarding/Index.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js index 4979f8e133..8ad0b3dc21 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -281,13 +281,21 @@ let _fieldValues = {}; + function _sanitizePreviewHtml(html) { + const value = String(html || ''); + const escaped = _escapeHtml(value); + return escaped + .replace(/<span class="text-muted">—<\/span>/g, ''); + } + function _updateFieldPreview(selectId, previewId, renderer) { const key = $('#' + selectId).val(); const raw = key ? (_fieldValues[key] ?? '') : ''; const text = raw ? String(raw) : ''; const $preview = $('#' + previewId); if (renderer) { - $preview.html(renderer(text, 'display') || ''); + const rendered = renderer(text, 'display') || ''; + $preview.html(_sanitizePreviewHtml(rendered)); } else { $preview.text(text || '—').toggleClass('text-muted', !text); } From 97756230d342c19921de5b1432fec045a3df8808 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 22 Jun 2026 11:03:22 -0700 Subject: [PATCH 09/51] AB#33309 address sonarqube feedback --- .../Pages/TenantManagement/Onboarding/Index.cshtml | 2 +- .../TenantManagement/Tenants/ConfigurationModal.cshtml | 10 +++++----- .../Pages/TenantManagement/Tenants/Index.cshtml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml index 853763404a..f134a44543 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml @@ -27,7 +27,7 @@
    - +
    diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml index 7dca6921d8..8cea4764ca 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml @@ -18,8 +18,8 @@ - - + +