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.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..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 @@ -1,8 +1,12 @@ -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; +using Volo.Abp.Application.Dtos; namespace Unity.Flex.WorksheetInstances { @@ -29,5 +33,60 @@ 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); + return instances.Select(wi => new WorksheetInstanceDataDto + { + CorrelationId = wi.CorrelationId, + WorksheetId = wi.WorksheetId, + CurrentValue = wi.CurrentValue + }).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); + 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); + } + + [RemoteService(false)] + 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.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.Identity.Web/src/Pages/Identity/Roles/index.js b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.js index 5c78335ae2..8693dfd7ba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.js +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.js @@ -61,7 +61,7 @@ $(function () { _identityRoleAppService .delete(data.record.id) .then(function () { - _dataTable.ajax.reloadEx(); + _dataTable.ajax.reload(); abp.notify.success(l('SuccessfullyDeleted')); }); }, @@ -174,10 +174,10 @@ $(function () { }); _createModal.onResult(function () { - _dataTable.ajax.reloadEx(); + _dataTable.ajax.reload(); }); _editModal.onResult(function () { - _dataTable.ajax.reloadEx(); + _dataTable.ajax.reload(); }); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js index eba6cf5da9..a5189339e8 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js @@ -65,7 +65,7 @@ $(function () { $('#ImportUserSearchButton').click(function (e) { e.preventDefault(); - _filterDataTable.ajax.reloadEx(); + _filterDataTable.ajax.reload(); $('#import-user-btn').attr('disabled', true); }); @@ -87,7 +87,7 @@ $(function () { }); _importModal.onResult(function () { - dataTable.ajax.reloadEx(); + dataTable.ajax.reload(); }); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json index b5b50c62aa..c31287fbfd 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json @@ -24,7 +24,7 @@ "Filter": "Filter", "Export": "Export", "NotificationList:NotificationId": "Notification Id", - "NotificationList:SubmissionId": "Submission Id", + "NotificationList:SubmissionId": "Submission #", "NotificationList:ApplicantName": "Applicant Name", "NotificationList:SentDate": "Sent Date", "NotificationList:Status": "Status", @@ -32,6 +32,10 @@ "NotificationList:To": "To", "NotificationList:Subject": "Subject", "NotificationList:Recipient": "Recipient", - "NotificationList:EmailType": "Email Type" + "NotificationList:EmailType": "Email Type", + "NotificationList:EmailType:Manual": "Manual", + "NotificationList:EmailType:Scheduled": "Scheduled", + "NotificationList:EmailType:EventBased": "Event Based", + "NotificationList:EmailType:Delayed": "Delayed" } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml index 163c82c20d..86f77e3eaf 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml @@ -10,12 +10,17 @@ } +@section styles { + +} +
-
- @L["Menu:Notifications"] - - - +
+
+ + +
+
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css new file mode 100644 index 0000000000..6a1faf3a05 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css @@ -0,0 +1,30 @@ +/* The shared initializeDataTable helper enables scrollX, and layout.css forces + a permanent vertical scrollbar on the scroll body. Together DataTables bakes + ~25px of vertical-scrollbar reservation into the computed column widths, so + the inner table ends up wider than its container and renders a phantom + horizontal scrollbar even when there is free space. + + `width: 100%` alone does nothing here because the table is table-layout: auto, + where the columns' content widths win over the width hint. Switching the + inner head/body tables to table-layout: fixed makes width: 100% authoritative, + so the table matches the container exactly; over-long values (full email + addresses) wrap inside their column instead of forcing a horizontal scroll. + Restoring overflow-y: auto lets the vertical scrollbar appear only when rows + actually overflow, removing the reserved gutter when they fit. + + Scoped to this table so the intentionally wide, horizontally scrolling tables + elsewhere are unaffected. */ +#NotificationListTable_wrapper .dt-scroll-head .dt-scroll-headInner { + width: 100% !important; +} + +#NotificationListTable_wrapper .dt-scroll-body { + overflow-y: auto !important; +} + +#NotificationListTable_wrapper .dt-scroll-head .dt-scroll-headInner table, +#NotificationListTable_wrapper .dt-scroll-body table, +#NotificationListTable { + table-layout: fixed !important; + width: 100% !important; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js index d1b0f0d7fd..9d3784ee7b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js @@ -13,7 +13,7 @@ $(function () { 'toAddress', 'subject', 'recipient', - 'emailType' + 'emailTypeText' ]; let actionButtons = [ @@ -58,12 +58,12 @@ $(function () { getSubmissionIdColumn(columnIndex++), getTextColumn(columnIndex++, l('NotificationList:ApplicantName'), 'applicantName'), getSentDateColumn(columnIndex++), - getTextColumn(columnIndex++, l('NotificationList:Status'), 'status'), + getTextColumn(columnIndex++, l('NotificationList:Status'), 'status', { width: '7rem', className: 'data-table-header text-nowrap' }), getTextColumn(columnIndex++, l('NotificationList:From'), 'fromAddress'), getTextColumn(columnIndex++, l('NotificationList:To'), 'toAddress'), getTextColumn(columnIndex++, l('NotificationList:Subject'), 'subject'), - getTextColumn(columnIndex++, l('NotificationList:Recipient'), 'recipient'), - getTextColumn(columnIndex++, l('NotificationList:EmailType'), 'emailType') + getTextColumn(columnIndex++, l('NotificationList:Recipient'), 'recipient', { width: '8rem', className: 'data-table-header text-nowrap' }), + getTextColumn(columnIndex++, l('NotificationList:EmailType'), 'emailTypeText', { width: '9rem', className: 'data-table-header text-nowrap' }) ]; return columns.map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })); } @@ -84,7 +84,8 @@ $(function () { title: l('NotificationList:SubmissionId'), name: 'submissionReferenceNo', data: 'submissionReferenceNo', - className: 'data-table-header', + className: 'data-table-header text-nowrap', + width: '8rem', index: columnIndex, render: function (data, type, row) { if (type !== 'display') { @@ -106,6 +107,7 @@ $(function () { name: 'sentDateTime', data: 'sentDateTime', className: 'data-table-header text-nowrap', + width: '8rem', index: columnIndex, render: function (data, type) { return DateUtils.formatUtcDateToLocal(data, type); @@ -122,12 +124,13 @@ function responseCallback(result) { }; } -function getTextColumn(columnIndex, title, dataField) { +function getTextColumn(columnIndex, title, dataField, options = {}) { return { title: title, name: dataField, data: dataField, - className: 'data-table-header', + className: options.className ?? 'data-table-header', + ...(options.width ? { width: options.width } : {}), index: columnIndex }; } 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.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js index 432d3339c3..d4511e78f5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js @@ -10,7 +10,7 @@ $(function () { } // Initialize DataTable for tenant view role management - $('#TenantViewRoleTable').DataTable({ + let tenantViewRoleTable = $('#TenantViewRoleTable').DataTable({ order: [[0, 'asc']], // Sort by tenant name processing: false, serverSide: false, @@ -18,6 +18,8 @@ $(function () { searching: true, pageLength: 25, autoWidth: false, + scrollY: 'calc(100vh - 325px)', + scrollCollapse: true, columnDefs: [ { targets: [2], // Actions column @@ -37,6 +39,14 @@ $(function () { } }); + // Keep the scroll body sized so the header/pagination stay within the viewport + // instead of the table overflowing past the bottom of the screen (same plugin + // used by initializeDataTable's fixedHeaders option elsewhere in the app). + // Stashed on the settings object, matching table-utils.js's own usage of the plugin. + if ($.fn.dataTable.ScrollResize) { + tenantViewRoleTable.settings()[0]._scrollResize = new $.fn.dataTable.ScrollResize(tenantViewRoleTable); + } + // Initialize tooltips on page load initializeTooltips(); 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/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..75fd8f7eb2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs @@ -0,0 +1,26 @@ +#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> 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/IOnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs new file mode 100644 index 0000000000..10cfd4acc0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs @@ -0,0 +1,18 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.TenantManagement; + +public interface IOnboardingRequestAppService : IApplicationService +{ + Task> GetListAsync(OnboardingListRequestDto input); + Task GetAsync(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/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..bea5f47225 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; @@ -6,8 +7,8 @@ namespace Unity.TenantManagement; public interface ITenantAppService : ICrudAppService { - Task GetDefaultConnectionStringAsync(Guid id); - Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString); - Task DeleteDefaultConnectionStringAsync(Guid id); Task AssignManagerAsync(TenantAssignManagerDto managerAssignment); + Task 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 10d36e8ec5..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 @@ -1,9 +1,14 @@ -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(); + + TenantDbCredentials GenerateReadOnlyCredentials(TenantDbCredentials credentials); + + string Build(string tenantName, TenantDbCredentials credentials); } } 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..6310942477 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs @@ -0,0 +1,15 @@ +#nullable enable +using System; +using System.Collections.Generic; + +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; + public Dictionary CoreFieldValues { get; set; } = []; +} 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 new file mode 100644 index 0000000000..c251999c11 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs @@ -0,0 +1,25 @@ +#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; + 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; } + public Dictionary Fields { 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..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,6 +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.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.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/EncryptedTenantConnectionStringResolver.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs new file mode 100644 index 0000000000..65257f69b3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs @@ -0,0 +1,50 @@ +using System; +using System.Security.Cryptography; +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; + if (PlainConnectionStringDetector.LooksLikePlainConnectionString(value)) return value; + + try + { + return _encryptionService.Decrypt(value); + } + catch (FormatException) + { + // Not valid base64, so it can't be ciphertext - it's plain text. + return value; + } + catch (CryptographicException) + { + // Valid base64 but failed to decrypt (wrong passphrase/corrupted ciphertext) - + // fall back to the raw value rather than breaking connection resolution. + return value; + } + } +} 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 new file mode 100644 index 0000000000..0bf2720292 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.TenantManagement.Onboarding; + +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", + ["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", + // 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; } + } + + /// + /// 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 []; + + 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() + .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..7b60631ad3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -0,0 +1,561 @@ +#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.Correlation; +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, + 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(); + + private IOnboardingUserLookup? UserLookup => + LazyServiceProvider.LazyGetService(); + + 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; + + // 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, IsProviderColumn); + var dynamicColumnFilters = GetDynamicColumnFilters(input.ColumnFilters, IsProviderColumn); + var (sortField, sortDescending) = ParseSorting(input.Sorting); + bool isDynamicSort = sortField != null && !IsProviderColumn(sortField); + + IReadOnlyList? globalDynamicMatchIds = null; + IReadOnlyList? dynamicColumnMatchIds = null; + Dictionary>? allInstancesByApp = null; + + // PERF: global filter, dynamic worksheet-field filters, and dynamic sort all require + // loading every application id + worksheet instance for the category into memory, since + // worksheet field values live in JSONB and can't be filtered/sorted in SQL. Fine at current + // onboarding-request volumes; revisit (e.g. bounded fetch, indexed projection) if this + // screen is reused/refactored for a larger dataset. + if (hasGlobalFilter || dynamicColumnFilters.Count > 0 || isDynamicSort) + { + 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) + { + // 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. + // PERF: int.MaxValue means no upper bound on this fetch — acceptable while onboarding + // request volume stays small; cap this if that changes. + 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 + { + // 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, providerSorting, 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 formVersionIds = await ApplicationProvider.GetFormVersionIdsAsync(resolvedCategory); + + if (formVersionIds.Count == 0) + return new OnboardingColumnSchemaDto { Columns = [] }; + + var seenWorksheetIds = 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) + { + List worksheets; + try { worksheets = await WorksheetAppService.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion); } + catch { continue; } + + foreach (var worksheet in worksheets) + { + 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)) + { + if (!seenKeys.Add(field.Key)) 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)) continue; + columns.Add(coreColumn); + } + + var saved = await ReadTenantMappingAsync(); + saved.Columns = columns; + return saved; + } + + public virtual async Task> GetAvailableCategoriesAsync() + { + 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, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null) + { + var request = await GetAsync(id); + if (request == null) + return new OnboardingValidationResultDto { IsValid = false, Issues = ["Onboarding request not found."] }; + + await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + + var issues = await RunValidationStepsAsync(request); + + return new OnboardingValidationResultDto { IsValid = issues.Count == 0, Issues = issues }; + } + + public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input) + { + 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); + + // 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(); + 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); + + 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 + }); + + foreach (var userGuid in userGuids.Skip(1)) + { + await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto + { + TenantId = tenantDto.Id, + UserIdentifier = userGuid + }); + } + + if (ApplicationProvider != null) + 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, + 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()!; + } + + private async Task SaveFieldMappingAsync(string? tenantNameKey, string? superUsersKey, string? branchKey, string? featuresKey, string? ministryKey, string? programAreaKey) + { + 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); + + return new OnboardingColumnSchemaDto + { + TenantNameFieldKey = tenantNameKey, + SuperUsersFieldKey = superUsersKey, + BranchFieldKey = branchKey, + FeaturesFieldKey = featuresKey, + MinistryFieldKey = ministryKey, + ProgramAreaFieldKey = programAreaKey + }; + } + + private static List GetStaticColumnFilters(List? filters, Func isProviderColumn) => + (filters ?? []) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && isProviderColumn(cf.Name)) + .ToList(); + + private static List GetDynamicColumnFilters(List? filters, Func isProviderColumn) => + (filters ?? []) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && !isProviderColumn(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 string? GetWorksheetFieldValue( + IReadOnlyList instances, string fieldKey) + { + foreach (var wi in instances) + { + 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; + } + + 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; } + }); + } + + private static bool MatchesColumnInWorksheet( + IReadOnlyList instances, string fieldKey, string value) + { + var lowerValue = value.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.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 + }; + + foreach (var (key, value) in app.CoreFieldValues) + dto.Fields[key] = value; + + 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/PlainConnectionStringDetector.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/PlainConnectionStringDetector.cs new file mode 100644 index 0000000000..63a42d0375 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/PlainConnectionStringDetector.cs @@ -0,0 +1,19 @@ +using System.Text.RegularExpressions; + +namespace Unity.TenantManagement.Application +{ + internal static partial class PlainConnectionStringDetector + { + // Require at least 2 keyword hits - a single hit (e.g. "Pwd=" or "Uid=") could + // coincidentally occur at the tail of valid base64 ciphertext, right before the + // padding '='. Real connection strings always carry several key=value pairs. + private const int MinKeywordMatches = 2; + + public static bool LooksLikePlainConnectionString(string value) + => ConnectionStringKeywordPattern().Matches(value).Count >= MinKeywordMatches; + + [GeneratedRegex(@"(Host|Server|Port|Database|Initial Catalog|Username|User Id|Uid|Pwd|Password|Data Source)\s*=", + RegexOptions.IgnoreCase)] + private static partial Regex ConnectionStringKeywordPattern(); + } +} 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 4d1e1b01b2..752f20b807 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,6 +1,8 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Unity.TenantManagement.Abstractions; @@ -11,13 +13,15 @@ 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; +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, @@ -25,8 +29,11 @@ public class TenantAppService( ITenantManager tenantManager, ILocalEventBus localEventBus, IUnitOfWorkManager unitOfWorkManager, - ITenantConnectionStringBuilder tenantConnectionStringBuilder) : TenantManagementAppServiceBase, ITenantAppService + 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"; @@ -131,21 +138,36 @@ 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)) - { + { tenant = await tenantManager.CreateAsync(input.Name); + 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))); + 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; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; @@ -164,7 +186,8 @@ await localEventBus.PublishAsync( Name = tenant.Name, Properties = { - { "UserIdentifier", input.UserIdentifier } + { "UserIdentifier", input.UserIdentifier }, + { "FeatureKeys", input.FeatureKeys ?? string.Empty } } } ); @@ -172,7 +195,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); @@ -204,35 +227,12 @@ public virtual async Task DeleteAsync(Guid id) await tenantRepository.DeleteAsync(tenant); } - [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] - public virtual async Task GetDefaultConnectionStringAsync(Guid id) - { - var tenant = await tenantRepository.GetAsync(id); - return tenant?.FindDefaultConnectionString(); - } - - [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] - public virtual async Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString) - { - var tenant = await tenantRepository.GetAsync(id); - tenant.SetDefaultConnectionString(defaultConnectionString); - await tenantRepository.UpdateAsync(tenant); - } - - [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] - public virtual async Task DeleteDefaultConnectionStringAsync(Guid id) - { - var tenant = await tenantRepository.GetAsync(id); - tenant.RemoveDefaultConnectionString(); - await tenantRepository.UpdateAsync(tenant); - } - - [RemoteService(false)] + [RemoteService(false)] [AllowAnonymous] 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() @@ -242,6 +242,7 @@ public async Task GetCurrentTenantName() return tenant?.Name ?? string.Empty; } + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] public async Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) { await localEventBus.PublishAsync( @@ -252,4 +253,77 @@ 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); + } + + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] + 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; + if (PlainConnectionStringDetector.LooksLikePlainConnectionString(rawValue)) return rawValue; + + try + { + return stringEncryptionService.Decrypt(rawValue); + } + catch (FormatException) + { + // Not valid base64, so it can't be ciphertext - it's plain text. + return rawValue; + } + catch (CryptographicException) + { + // Valid base64 but failed to decrypt (wrong passphrase/corrupted ciphertext) - + // fall back to the raw value rather than breaking the admin UI. + 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 1e83ae5238..7a0058bccf 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,120 @@ -using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +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(); + + // Deliberately excludes quote/backslash characters — the password is interpolated into + // a single-quoted SQL literal by EntityFrameworkCoreGrantManagerDbSchemaMigrator, so + // restricting the charset here removes that injection surface at the source rather than + // relying solely on escaping at the consuming end. + private static readonly char[] PasswordAlphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".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, TenantDbCredentials credentials) + { + 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 + }); + } + + // 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 string Build(string tenantName) + public async Task GenerateCredentialsAsync() { - var connectionString = _configuration.GetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName); + var allTenants = await _tenantRepository.GetListAsync(nameof(Tenant.Name), int.MaxValue, 0, null, includeDetails: true); - return connectionString == null - ? throw new UserFriendlyException("Connection string configuration error") - : connectionString - .Replace(UnityTenantManagementConsts.TenantConnectionStringTenantDb, PrepTenantName(tenantName)); + 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 (existingDbNames.Contains(dbName)); + + 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 + Span chars = + [ + 'T', + '_', + Letters[RandomNumberGenerator.GetInt32(Letters.Length)], + Letters[RandomNumberGenerator.GetInt32(Letters.Length)], + Letters[RandomNumberGenerator.GetInt32(Letters.Length)], + Digits[RandomNumberGenerator.GetInt32(Digits.Length)], + Digits[RandomNumberGenerator.GetInt32(Digits.Length)], + Digits[RandomNumberGenerator.GetInt32(Digits.Length)] + ]; + return new string(chars); } - private static string PrepTenantName(string tenantName) + private static string GeneratePassword() { - return tenantName.Trim().Replace(" ", ""); + // 24 cryptographically random characters (mixed-case letters + digits) — generated + // via RandomNumberGenerator (CSPRNG), not the non-cryptographic Random.Shared, since + // this protects a live PostgreSQL login role's credentials. + const int length = 24; + Span chars = new char[length]; + for (var i = 0; i < length; i++) + { + chars[i] = PasswordAlphabet[RandomNumberGenerator.GetInt32(PasswordAlphabet.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..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 @@ -15,12 +15,16 @@ + + + + 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/UnityTenantManagementConsts.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs index 0c6d6833fe..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 TenantConnectionStringTenantDb = "UnityGrantTenant"; + 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..7db1572c3c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs @@ -0,0 +1,67 @@ +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; + +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) + { + 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.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.Generated.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.Generated.cs index 8afbfed3f6..187ad02c74 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.Generated.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.Generated.cs @@ -53,28 +53,4 @@ public virtual async Task DeleteAsync(Guid id) }); } - public virtual async Task GetDefaultConnectionStringAsync(Guid id) - { - return await RequestAsync(nameof(GetDefaultConnectionStringAsync), new ClientProxyRequestTypeValue - { - { typeof(Guid), id } - }); - } - - public virtual async Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString) - { - await RequestAsync(nameof(UpdateDefaultConnectionStringAsync), new ClientProxyRequestTypeValue - { - { typeof(Guid), id }, - { typeof(string), defaultConnectionString } - }); - } - - public virtual async Task DeleteDefaultConnectionStringAsync(Guid id) - { - await RequestAsync(nameof(DeleteDefaultConnectionStringAsync), new ClientProxyRequestTypeValue - { - { typeof(Guid), id } - }); - } } 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.Client/ClientProxies/multi-tenancy-generate-proxy.json b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/multi-tenancy-generate-proxy.json index 44e4a7eeb5..97c9abf536 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/multi-tenancy-generate-proxy.json +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/multi-tenancy-generate-proxy.json @@ -16,65 +16,6 @@ "type": "Unity.TenantManagement.ITenantAppService", "name": "ITenantAppService", "methods": [ - { - "name": "GetDefaultConnectionStringAsync", - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "returnValue": { - "type": "System.String", - "typeSimple": "string" - } - }, - { - "name": "UpdateDefaultConnectionStringAsync", - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - }, - { - "name": "defaultConnectionString", - "typeAsString": "System.String, System.Private.CoreLib", - "type": "System.String", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - } - }, - { - "name": "DeleteDefaultConnectionStringAsync", - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - } - }, { "name": "GetAsync", "parametersOnMethod": [ @@ -412,137 +353,6 @@ }, "allowAnonymous": null, "implementFrom": "Unity.Application.Services.IDeleteAppService" - }, - "GetDefaultConnectionStringAsyncById": { - "uniqueName": "GetDefaultConnectionStringAsyncById", - "name": "GetDefaultConnectionStringAsync", - "httpMethod": "GET", - "url": "api/multi-tenancy/tenants/{id}/default-connection-string", - "supportedVersions": [], - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "parameters": [ - { - "nameOnMethod": "id", - "name": "id", - "jsonName": null, - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": [], - "bindingSourceId": "Path", - "descriptorName": "" - } - ], - "returnValue": { - "type": "System.String", - "typeSimple": "string" - }, - "allowAnonymous": null, - "implementFrom": "Unity.TenantManagement.ITenantAppService" - }, - "UpdateDefaultConnectionStringAsyncByIdAndDefaultConnectionString": { - "uniqueName": "UpdateDefaultConnectionStringAsyncByIdAndDefaultConnectionString", - "name": "UpdateDefaultConnectionStringAsync", - "httpMethod": "PUT", - "url": "api/multi-tenancy/tenants/{id}/default-connection-string", - "supportedVersions": [], - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - }, - { - "name": "defaultConnectionString", - "typeAsString": "System.String, System.Private.CoreLib", - "type": "System.String", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "parameters": [ - { - "nameOnMethod": "id", - "name": "id", - "jsonName": null, - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": [], - "bindingSourceId": "Path", - "descriptorName": "" - }, - { - "nameOnMethod": "defaultConnectionString", - "name": "defaultConnectionString", - "jsonName": null, - "type": "System.String", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": null, - "bindingSourceId": "ModelBinding", - "descriptorName": "" - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - }, - "allowAnonymous": null, - "implementFrom": "Unity.TenantManagement.ITenantAppService" - }, - "DeleteDefaultConnectionStringAsyncById": { - "uniqueName": "DeleteDefaultConnectionStringAsyncById", - "name": "DeleteDefaultConnectionStringAsync", - "httpMethod": "DELETE", - "url": "api/multi-tenancy/tenants/{id}/default-connection-string", - "supportedVersions": [], - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "parameters": [ - { - "nameOnMethod": "id", - "name": "id", - "jsonName": null, - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": [], - "bindingSourceId": "Path", - "descriptorName": "" - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - }, - "allowAnonymous": null, - "implementFrom": "Unity.TenantManagement.ITenantAppService" } } } 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..e00ada72a8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs @@ -0,0 +1,69 @@ +#nullable enable +using System; +using System.Collections.Generic; +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/onboarding-requests")] +public class OnboardingRequestController(IOnboardingRequestAppService onboardingRequestAppService) + : AbpControllerBase, IOnboardingRequestAppService +{ + protected IOnboardingRequestAppService OnboardingRequestAppService { get; } = onboardingRequestAppService; + + [HttpGet] + public virtual Task> GetListAsync([FromQuery] OnboardingListRequestDto 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, + [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, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + } + + [HttpPost("{id}/create-tenant")] + public virtual Task CreateTenantAsync(Guid id, [FromBody] CreateTenantInputDto? input = null) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->CreateTenantAsync: ModelState Invalid"); + 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.HttpApi/TenantController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs index f87e1706bf..e036ba383c 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; @@ -69,46 +70,46 @@ public virtual Task DeleteAsync(Guid id) } [HttpGet] - [Route("{id}/default-connection-string")] - public virtual Task GetDefaultConnectionStringAsync(Guid id) + [Route("{id}/managers")] + public virtual Task> GetManagersAsync(Guid id) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->GetDefaultConnectionStringAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->GetManagersAsync: ModelState Invalid"); } - return TenantAppService.GetDefaultConnectionStringAsync(id); + return TenantAppService.GetManagersAsync(id); } [HttpPut] - [Route("{id}/default-connection-string")] - public virtual Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString) + [Route("assign-manager")] + public virtual Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->UpdateDefaultConnectionStringAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->AssignManagerAsync: ModelState Invalid"); } - return TenantAppService.UpdateDefaultConnectionStringAsync(id, defaultConnectionString); + return TenantAppService.AssignManagerAsync(managerAssignment); } - [HttpDelete] - [Route("{id}/default-connection-string")] - public virtual Task DeleteDefaultConnectionStringAsync(Guid id) + [HttpGet] + [Route("{id}/connection-strings")] + public virtual Task GetConnectionStringsAsync(Guid id) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->DeleteDefaultConnectionStringAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->GetConnectionStringsAsync: ModelState Invalid"); } - return TenantAppService.DeleteDefaultConnectionStringAsync(id); + return TenantAppService.GetConnectionStringsAsync(id); } [HttpPut] - [Route("assign-manager")] - public virtual Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) + [Route("{id}/connection-strings")] + public virtual Task UpdateConnectionStringsAsync(Guid id, [FromBody] TenantConnectionStringsDto input) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->AssignManagerAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->UpdateConnectionStringsAsync: ModelState Invalid"); } - return TenantAppService.AssignManagerAsync(managerAssignment); + return TenantAppService.UpdateConnectionStringsAsync(id, input); } } 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/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..f396127cdf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml @@ -0,0 +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; +} + + + + + + + + +
+ + @L["CreateTenantModal:Validating"] +
+
+ +
+
+ + @L["CreateTenantModal:CreatingSpinner"] +
+
+ @L["CreateTenantModal:CreatingWarningTitle"] + @L["CreateTenantModal:CreatingWarningBody"] +
+
+ +
+ + + + +
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..f134a44543 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml @@ -0,0 +1,48 @@ +@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(L["Menu:Onboarding"].Value); + PageLayout.Content.MenuItemName = TenantManagementMenuNames.Onboarding; + ViewBag.PageTitle = L["Menu:Onboarding"].Value; +} +@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..eb7317d294 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -0,0 +1,622 @@ +(function () { + let l = abp.localization.getResource('GrantManager'); + let _onboardingRequestAppService = unity.tenantManagement.onboardingRequest; + let _dataTable = null; + 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, + 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'), + 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) + .replaceAll(/([a-z])([A-Z])/g, '$1 $2') + .replaceAll(/\b\w/g, function (c) { return c.toUpperCase(); }); + } + + // Worksheet CheckboxGroup values are always serialized as [{key, value}, ...]. + function _extractCheckboxLabels(data) { + if (data === null || data === undefined || data === '') return []; + try { + const items = JSON.parse(data); + if (Array.isArray(items)) { + return items.filter(function (i) { return i?.value === true; }).map(function (i) { return i.key; }); + } + } catch { + // not a CheckboxGroup value + } + return []; + } + + function _renderCheckboxGroup(data, type) { + // No value submitted at all for this field — leave the cell blank, as opposed to + // data being present but resolving to zero checked labels (handled below). + if (data === null || data === undefined || data === '') return ''; + + const labels = _extractCheckboxLabels(data); + + 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(''); + } + + // ─── 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('@'); }); + } + + // ─── DOM-based preview builders ────────────────────────────────────────── + // Used by _updateFieldPreview below, which injects these via DOM append rather than + // .html() — these build real elements with jQuery's .text() for any dynamic content + // (the worksheet field value), so there's no HTML-string sink for CodeQL/XSS scanners + // to flag, unlike the string-concatenation renderers above (which still serve the + // DataTables column-render path, where a string return is required). + + function _buildMutedPlaceholder() { + return $('').addClass('text-muted').text('—'); + } + + function _buildCheckboxBadgesPreview(data) { + const labels = _extractCheckboxLabels(data); + if (labels.length === 0) return _buildMutedPlaceholder(); + + return $(labels.map(function (label) { + return $('') + .addClass('badge rounded-pill bg-light text-dark border me-1 onboarding-checkbox-badge') + .text(_formatCheckboxKey(label)) + .get(0); + })); + } + + function _buildSuperUsersPreview(data) { + const emails = _extractDataGridEmails(data); + if (emails === null) { + return data ? $('').text(data) : _buildMutedPlaceholder(); + } + return emails.length ? $('').text(emails.join(', ')) : _buildMutedPlaceholder(); + } + + // ─── 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.includes(cell.key)) 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, domBuilder) { + const key = $('#' + selectId).val(); + const raw = key ? (_fieldValues[key] ?? '') : ''; + const text = raw ? String(raw) : ''; + const $preview = $('#' + previewId); + if (domBuilder) { + // domBuilder() returns real jQuery/DOM elements built with .text(), not an HTML + // string — appended directly so there's no .html()-of-untrusted-string sink. + $preview.empty().append(domBuilder(text)); + } else { + $preview.text(text || '—').toggleClass('text-muted', !text); + } + } + + // ─── Create Tenant modal ────────────────────────────────────────────────── + + 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('
' + l('OnboardingModal:ValidationPassed') + '
') + .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('
    ' + l('OnboardingModal:ValidationFailed') + '
      ' + issuesHtml + '
    ') + .removeClass('d-none'); + } + } + + function _renderValidationFail() { + $('#onboarding-validation-loading').addClass('d-none'); + $('#onboarding-validation-result') + .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 () { + 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/onboarding-requests/' + applicationId + '/create-tenant', + type: 'POST', + data: JSON.stringify({ tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey }), + contentType: 'application/json' + }).done(function () { + abp.notify.success(l('OnboardingModal:CreateSuccess')); + _createTenantModal.close(); + _dataTable.ajax.reload(); + }).fail(function () { + $('#onboarding-creating').addClass('d-none'); + $btn.prop('disabled', false); + $('#btn-cancel-create-tenant').prop('disabled', false); + 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', _buildCheckboxBadgesPreview); + }); + $('#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', _buildSuperUsersPreview); + _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', _buildCheckboxBadgesPreview); + _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); + _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value', _buildSuperUsersPreview); + $('#create-tenant-field-mapping').show(); + _wireCreateTenantMappingHandlers(applicationId); + _triggerValidation(applicationId); + }).fail(function () { + _renderValidationFail(); + }); + } + + abp.modals.createTenantModal = function () { + return { + initModal: function (publicApi, args) { + const applicationId = args.id; + + 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)); + } + }; + }; + + // ─── Jaro-Winkler field auto-detection ─────────────────────────────────── + + function _normalizeLabel(s) { + return s + .replaceAll(/([a-z])([A-Z])/g, '$1 $2') + .replaceAll(/[_-]+/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 }; + } + + 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 _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); + } + + 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', 'features to be enabled']; + 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; + } + + function _renderMappingDropdown(selectId, fields, canonicals, savedKey) { + const $sel = $('#' + selectId); + $sel.find('option:not(:first)').remove(); + fields.forEach(function (f) { + $sel.append(''); + }); + const savedKeyValid = savedKey && fields.some(function (f) { return f.key === savedKey; }); + const pick = (savedKeyValid ? savedKey : null) || _bestMatch(fields, canonicals); + if (pick) $sel.val(pick); + } + + // ─── 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); + }); + } + + $(function () { + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/categories', + type: 'GET' + }).done(function (categories) { + const categoryList = categories || ['Onboarding']; + const $sel = $('#onboarding-category-filter'); + $sel.empty(); + 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(); + }); + + $('#onboarding-category-filter').on('change', function () { + _selectedCategory = $(this).val() || 'Onboarding'; + localStorage.setItem(CATEGORY_STORAGE_KEY, _selectedCategory); + _loadSchemaAndInitTable(); + }); + + $('#btn-open-onboarding').on('click', function () { + if (!_selectedRow) return; + globalThis.location.href = '/GrantApplications/Details?ApplicationId=' + _selectedRow.id; + }); + + $('#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..8cea4764ca --- /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..f410abec4f 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,17 @@ @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..3b7b834ed4 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,157 +1,90 @@ (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 = {}; - 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('' + lGm('TenantList:ConfigurationAction') + ''); } - ); + 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: 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: lGm('TenantList:CasClientCode'), + 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 +99,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,17 +136,16 @@ className: 'data-table-header' }], }) - ) + ); $('#TenantAdminSearchButton').click(function (e) { e.preventDefault(); - _filterDataTable.ajax.reloadEx(); + _filterDataTable.ajax.reload(); $('#create-tenant-btn').attr('disabled', true); }); $('#cancel-tenant-btn').click(function (e) { _createModal.close(); - _assignManagerModal.close(); }); _filterDataTable.on('select', function (e, dt, type, indexes) { @@ -216,15 +160,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 +168,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().replaceAll(/\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(lGm('TenantList:SearchMinChars')); + return; + } + _configFilterDataTable.ajax.reload(); + $('#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.reload(); + 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,38 +422,62 @@ 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, + 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', + fixedHeaders: true + }); + + // 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(); + _dataTable.ajax.reload(); }); - _editModal.onResult(function () { - _dataTable.ajax.reloadEx(); + _configurationModal.onResult(function () { + _dataTable.ajax.reload(); }); - $('#AbpContentToolbar button[name=CreateTenant]').click(function (e) { + // Relocate the page-toolbar "New Tenant" button into the action bar, to the + // left of the Filter button, matching the Endpoints list layout. + $('#tenantCreateButtonContainer').append($('#AbpContentToolbar button[name=CreateTenant]')); + + $('#tenantCreateButtonContainer button[name=CreateTenant]').click(function (e) { 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 +486,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..c5014d6c15 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 @@ -44,24 +44,86 @@ dataType: null }, ajaxParams)); }; - unity.tenantManagement.tenant.getDefaultConnectionString = function(id, ajaxParams) { + unity.tenantManagement.tenant.getConnectionStrings = function(id, ajaxParams) { return abp.ajax($.extend(true, { - url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/default-connection-string', + url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/connection-strings', type: 'GET' - }, { dataType: 'text' }, ajaxParams)); + }, ajaxParams)); }; - unity.tenantManagement.tenant.updateDefaultConnectionString = function(id, defaultConnectionString, ajaxParams) { + unity.tenantManagement.tenant.updateConnectionStrings = function(id, input, ajaxParams) { return abp.ajax($.extend(true, { - url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/default-connection-string' + abp.utils.buildQueryString([{ name: 'defaultConnectionString', value: defaultConnectionString }]) + '', + url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/connection-strings', type: 'PUT', - dataType: null + data: JSON.stringify(input) + }, ajaxParams)); + }; + + })(); + + // controller unity.tenantManagement.onboardingRequest + + (function(){ + + 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/onboarding-requests' + abp.utils.buildQueryString(qsParams) + '', + type: 'GET' }, ajaxParams)); }; - unity.tenantManagement.tenant.deleteDefaultConnectionString = function(id, ajaxParams) { + + unity.tenantManagement.onboardingRequest.get = function(id, ajaxParams) { return abp.ajax($.extend(true, { - url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/default-connection-string', - type: 'DELETE', - dataType: null + 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/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..e5e7e990a6 --- /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..7667f51dfa --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs @@ -0,0 +1,472 @@ +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.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>()) + .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 formVersionId = Guid.NewGuid(); + var ws1 = Guid.NewGuid(); + var ws2 = Guid.NewGuid(); + + _applicationProvider.GetFormVersionIdsAsync("Onboarding").Returns(new List { formVersionId }); + + _worksheetAppService.GetListByCorrelationAsync(formVersionId, "FormVersion").Returns(new List + { + 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 } + ]} + ] + } + }); + + 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 + { + 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"]); + } + + [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() + { + 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] + 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/TenantAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs index 37dcc34065..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; @@ -19,7 +21,12 @@ 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.GenerateReadOnlyCredentials(Arg.Any()) + .Returns(new TenantDbCredentials("T_ABC123", "T_ABC123_readonly", "RO12345")); + tenantConnectionStringBuilder.Build(Arg.Any(), Arg.Any()) + .Returns("acme test connection"); services.AddSingleton(tenantConnectionStringBuilder); } @@ -75,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/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..972c606df6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs @@ -0,0 +1,164 @@ +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); + } + + [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 +{ + 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. + 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/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.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/Chefs/IFormsApiService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Chefs/IFormsApiService.cs index d3b6276b71..6fac6da835 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Chefs/IFormsApiService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Chefs/IFormsApiService.cs @@ -8,6 +8,7 @@ namespace Unity.GrantManager.Integrations.Chefs public interface IFormsApiService : IApplicationService { Task GetFormDataAsync(string chefsFormId, string chefsFormVersionId); + Task GetFormVersionDataAsync(Guid applicationFormId, string chefsFormVersionId); Task GetForm(Guid? formId, string chefsApplicationFormGuid, string encryptedApiKey); Task GetSubmissionDataAsync(Guid chefsFormId, Guid submissionId); } 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.Contracts/Notifications/NotificationSummaryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationSummaryDto.cs index 249ca709fa..f623f6bb73 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationSummaryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationSummaryDto.cs @@ -16,4 +16,5 @@ public class NotificationSummaryDto : EntityDto public string Subject { get; set; } = string.Empty; public RecipientType? Recipient { get; set; } public EmailType? EmailType { get; set; } + public string EmailTypeText { get; set; } = string.Empty; } 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..63fa4d8cf8 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,15 @@ private async Task UpdateApplicationFormVersionFields(ApplicationFormVersion app public async Task GetFormVersionByApplicationIdAsync(Guid applicationId) { var formSubmission = await formSubmissionRepository.GetByApplicationAsync(applicationId); + + if (formSubmission == null) + { + return 0; + } if (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/Events/DateBasedScheduledNotificationJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs index 6cd014d183..db4f3d7bc6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs @@ -37,7 +37,6 @@ public class DateBasedScheduledNotificationJob : QuartzBackgroundWorkerBase, ITr private readonly IRepository _scheduledNotificationRepository; private readonly IApplicationRepository _applicationRepository; private readonly IRepository _trackingRepository; - private readonly IRepository _emailLogRepository; private readonly IApplicantAgentRepository _applicantAgentRepository; private readonly ITenantRepository _tenantRepository; private readonly ILocalEventBus _localEventBus; @@ -55,7 +54,6 @@ public DateBasedScheduledNotificationJob( IRepository scheduledNotificationRepository, IApplicationRepository applicationRepository, IRepository trackingRepository, - IRepository emailLogRepository, IApplicantAgentRepository applicantAgentRepository, ITenantRepository tenantRepository, ILocalEventBus localEventBus, @@ -73,7 +71,6 @@ public DateBasedScheduledNotificationJob( _scheduledNotificationRepository = scheduledNotificationRepository; _applicationRepository = applicationRepository; _trackingRepository = trackingRepository; - _emailLogRepository = emailLogRepository; _applicantAgentRepository = applicantAgentRepository; _tenantRepository = tenantRepository; _localEventBus = localEventBus; @@ -380,7 +377,7 @@ await _scheduledNotificationHelper.PublishToExternalRecipientAsync( } // Create tracking record to prevent duplicate notifications - return CreateTrackingRecord(notification, application, isDraft: false); + return CreateTrackingRecord(notification, application); } catch (Exception ex) { @@ -391,62 +388,15 @@ await _scheduledNotificationHelper.PublishToExternalRecipientAsync( } } - private async Task CreateDraftEmailAsync( - ScheduledNotification notification, - Application application, - EmailTemplate template, - string subject, - string body, - string emailFrom, - string toAddress = "") - { - try - { - var draftEmail = new EmailLog - { - TenantId = _currentTenant.Id, - ScheduledNotificationId = notification.Id, - ApplicationId = application.Id, - ApplicantId = application.ApplicantId, - FromAddress = emailFrom, - ToAddress = toAddress, // Populated if recipient is available - Subject = subject, - Body = body, - BodyType = "HTML", - Priority = "Normal", - TemplateName = template.Name, - Tag = "DateBasedScheduledNotificationJob", // Identifies source as background job - Status = EmailStatus.Draft, - EmailType = EmailType.DateBased, // Distinguish from event-based emails created by event handler - RetryAttempts = 0 - }; - - await _emailLogRepository.InsertAsync(draftEmail); - _logger.LogInformation( - "DateBasedScheduledNotificationJob: Draft email created for scheduled notification {NotificationId} with subject '{Subject}'.", - notification.Id, subject); - } - catch (Exception ex) - { - _logger.LogError(ex, - "DateBasedScheduledNotificationJob: Error creating draft email for scheduled notification {NotificationId}.", - notification.Id); - } - } - private ScheduledNotificationTracking? CreateTrackingRecord( ScheduledNotification notification, - Application application, - bool isDraft = false) + Application application) { if (string.IsNullOrEmpty(notification.DateField)) { - if (!isDraft) - { - _logger.LogWarning( - "DateBasedScheduledNotificationJob: DateField is null or empty for notification {NotificationId}, cannot create tracking record.", - notification.Id); - } + _logger.LogWarning( + "DateBasedScheduledNotificationJob: DateField is null or empty for notification {NotificationId}, cannot create tracking record.", + notification.Id); return null; } @@ -457,12 +407,9 @@ private async Task CreateDraftEmailAsync( notification.DateField, DateTime.UtcNow); - if (!isDraft) - { - _logger.LogInformation( - "DateBasedScheduledNotificationJob: Notification {NotificationId} ready to be tracked for application {ApplicationId}.", - notification.Id, application.Id); - } + _logger.LogInformation( + "DateBasedScheduledNotificationJob: Notification {NotificationId} ready to be tracked for application {ApplicationId}.", + notification.Id, application.Id); return tracking; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs index e980ad1d6c..82200085a5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs @@ -22,8 +22,6 @@ namespace Unity.GrantManager.Events { internal class ScheduledNotificationEventHandler( IRepository scheduledNotificationRepository, - IRepository emailLogRepository, - IRepository trackingRepository, IApplicationRepository applicationRepository, IApplicantAgentRepository applicantAgentRepository, ILocalEventBus localEventBus, @@ -149,61 +147,5 @@ await scheduledNotificationHelper.PublishToExternalRecipientAsync( notification.RecipientCategory, notification.Id); } } - - private async Task CreateDraftEmailAsync( - ScheduledNotification notification, - Application application, - EmailTemplate template, - string subject, - string body, - string emailFrom, - string toAddress = "") - { - try - { - var draftEmail = new EmailLog - { - TenantId = currentTenant.Id, - ScheduledNotificationId = notification.Id, - ApplicationId = application.Id, - ApplicantId = application.ApplicantId, - FromAddress = emailFrom, - ToAddress = toAddress, // Populated if recipient is available - Subject = subject, - Body = body, - BodyType = "HTML", - Priority = "Normal", - TemplateName = template.Name, - Tag = "ScheduledNotificationEventHandler", // Identifies source as event handler - Status = EmailStatus.Draft, - EmailType = EmailType.EventBased, - RetryAttempts = 0 - }; - - await emailLogRepository.InsertAsync(draftEmail); - - // Create tracking record to mark that this notification has been processed for this application - if (!string.IsNullOrEmpty(notification.DateField)) - { - var tracking = new ScheduledNotificationTracking( - Guid.NewGuid(), - application.Id, - notification.Id, - notification.DateField, - DateTime.UtcNow); - await trackingRepository.InsertAsync(tracking); - } - - logger.LogInformation( - "ScheduledNotificationEventHandler: Draft email created for scheduled notification {NotificationId} with subject '{Subject}'.", - notification.Id, subject); - } - catch (Exception ex) - { - logger.LogError(ex, - "ScheduledNotificationEventHandler: Error creating draft email for scheduled notification {NotificationId}.", - notification.Id); - } - } } } 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 469414348b..87a4db3b4b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -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; @@ -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))) 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..7f725c1034 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,33 @@ 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)) + return; + + var featureUpdates = BuildFeatureUpdates(featureKeysRaw); + if (featureUpdates.Count == 0) return; + + await _featureAppService.UpdateAsync( + "T", // TenantFeatureValueProvider.ProviderName + 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/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..0fdbe22eaf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs @@ -71,7 +71,7 @@ public async Task ImportUserAsync(ImportUserDto importUserDto) var displayName = cssUser.Attributes?.DisplayName?[0] ?? identityUser.NormalizedUserName.ToString(); await UpdateAdditionalUserPropertiesAsync(identityUser, oidcSub, displayName); - await SyncUserToCurrentTenantAsync(newUserId, identityUser, oidcSub, displayName); + await SyncUserToCurrentTenantAsync(identityUser, oidcSub, displayName); } /// @@ -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); } /// @@ -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"); @@ -227,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/Intakes/IntakeFormSubmissionManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs index a44176990f..822c169ec1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Linq; @@ -8,6 +7,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Intakes.Events; +using Unity.GrantManager.Integrations.Chefs; using Unity.GrantManager.Intakes.Mapping; using Volo.Abp; using Volo.Abp.Domain.Repositories; @@ -25,8 +25,10 @@ public class IntakeFormSubmissionManager(IUnitOfWorkManager _unitOfWorkManager, IApplicationFormSubmissionRepository _applicationFormSubmissionRepository, IIntakeFormSubmissionMapper _intakeFormSubmissionMapper, IApplicationFormVersionRepository _applicationFormVersionRepository, + IApplicationFormRepository _applicationFormRepository, CustomFieldsIntakeSubmissionMapper _customFieldsIntakeSubmissionMapper, ILocalEventBus localEventBus, + IFormsApiService _formsApiService, ISequenceRepository _sequenceRepository) : DomainService, IIntakeFormSubmissionManager { public async Task GetApplicationFormVersionMapping(string chefsFormVersionId) @@ -251,13 +253,43 @@ private async Task CreateNewApplicationAsync(IntakeMapping intakeMa public async Task ResyncSubmissionAttachments(Guid applicationId) { - var query = from applicationFormSubmission in await _applicationFormSubmissionRepository.GetQueryableAsync() - where applicationFormSubmission.ApplicationId == applicationId - select applicationFormSubmission; - ApplicationFormSubmission? applicationFormSubmissionData = await AsyncExecuter.FirstOrDefaultAsync(query); - if (applicationFormSubmissionData == null) return; - var formSubmission = JsonConvert.DeserializeObject(applicationFormSubmissionData.Submission)!; + var query = from s in await _applicationFormSubmissionRepository.GetQueryableAsync() + where s.ApplicationId == applicationId + select s; + var submissionRecord = await AsyncExecuter.FirstOrDefaultAsync(query); + if (submissionRecord == null) return; + + var applicationForm = await _applicationFormRepository.GetAsync(submissionRecord.ApplicationFormId); + + if (!Guid.TryParse(applicationForm.ChefsApplicationFormGuid, out var chefsFormId)) + { + Logger.LogWarning("ResyncSubmissionAttachments: invalid ChefsApplicationFormGuid on form {FormId}", submissionRecord.ApplicationFormId); + throw new BusinessException(message: $"Application form {submissionRecord.ApplicationFormId} has an invalid CHEFS form GUID."); + } + + if (!Guid.TryParse(submissionRecord.ChefsSubmissionGuid, out var chefsSubmissionId)) + { + Logger.LogWarning("ResyncSubmissionAttachments: invalid ChefsSubmissionGuid on submission record {RecordId}", submissionRecord.Id); + throw new BusinessException(message: $"Submission record {submissionRecord.Id} has an invalid CHEFS submission GUID."); + } + + var formSubmission = await _formsApiService.GetSubmissionDataAsync(chefsFormId, chefsSubmissionId); + + if (formSubmission == null) + { + Logger.LogWarning("ResyncSubmissionAttachments: CHEFS returned no data for submission {SubmissionId}", chefsSubmissionId); + throw new BusinessException(message: $"CHEFS returned no data for submission {chefsSubmissionId}. Resync aborted — existing attachments are unchanged."); + } + + if (formSubmission["submission"]?["submission"]?["data"] == null || formSubmission["version"] == null) + { + Logger.LogWarning("ResyncSubmissionAttachments: CHEFS response for submission {SubmissionId} has unexpected shape", chefsSubmissionId); + throw new BusinessException(message: $"CHEFS response for submission {chefsSubmissionId} has unexpected shape. Resync aborted — existing attachments are unchanged."); + } + + using var uow = _unitOfWorkManager.Begin(isTransactional: true); await _intakeFormSubmissionMapper.ResyncSubmissionAttachments(applicationId, formSubmission); + await uow.SaveChangesAsync(); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs index e77af5aeb7..28a4e2b8ae 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs @@ -65,6 +65,23 @@ public async Task GetForm(Guid? formId, string chefsApplicationFormGuid return await ParseJsonResponseAsync(response) ?? []; } + public async Task GetFormVersionDataAsync(Guid applicationFormId, string chefsFormVersionId) + { + var applicationForm = await applicationFormRepository.GetAsync(applicationFormId); + + if (applicationForm == null || string.IsNullOrEmpty(applicationForm.ChefsApplicationFormGuid)) + { + logger.LogWarning("No application form or CHEFS configuration found for ApplicationFormId: {ApplicationFormId}", applicationFormId); + return null; + } + + string chefsApi = await GetChefsApiBaseUrlAsync(); + string url = $"{chefsApi}/forms/{applicationForm.ChefsApplicationFormGuid}/versions/{chefsFormVersionId}"; + + var response = await GetRequestAsync(url, applicationForm.ChefsApplicationFormGuid!, applicationForm.ApiKey!); + return await ParseJsonResponseAsync(response); + } + public async Task GetSubmissionDataAsync(Guid chefsFormId, Guid submissionId) { var applicationForm = await (await applicationFormRepository.GetQueryableAsync()) 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.Application/Norifications/NotificationListAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationListAppService.cs index 1721b95ba4..100b42decc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationListAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationListAppService.cs @@ -3,8 +3,10 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Localization; using Unity.GrantManager.Applications; using Unity.Notifications.Emails; +using Unity.Notifications.Localization; using Unity.Notifications.Permissions; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -18,7 +20,8 @@ namespace Unity.GrantManager.Notifications; public class NotificationListAppService( IEmailLogsRepository emailLogsRepository, IRepository applicationRepository, - IRepository applicantRepository) + IRepository applicantRepository, + IStringLocalizer notificationsLocalizer) : ApplicationService, INotificationListAppService { public virtual async Task> GetListAsync(PagedAndSortedResultRequestDto input) @@ -62,13 +65,28 @@ public virtual async Task> GetListAsync(P ToAddress = log.ToAddress, Subject = log.Subject, Recipient = log.Recipient, - EmailType = log.EmailType + EmailType = log.EmailType, + EmailTypeText = GetEmailTypeText(log.EmailType) }; }).ToList(); return new PagedResultDto(totalCount, items); } + // Maps the EmailType enum to the user-facing label shown on the Notification List + private string GetEmailTypeText(EmailType? emailType) + { + return emailType switch + { + EmailType.Manual => notificationsLocalizer["NotificationList:EmailType:Manual"].Value, + EmailType.DateBased => notificationsLocalizer["NotificationList:EmailType:Scheduled"].Value, + EmailType.EventBased => notificationsLocalizer["NotificationList:EmailType:EventBased"].Value, + EmailType.Delayed => notificationsLocalizer["NotificationList:EmailType:Delayed"].Value, + // Fallback for any future unmapped value: show the raw enum name rather than a blank cell. + _ => emailType?.ToString() ?? string.Empty + }; + } + private const string DefaultSorting = "SentDateTime DESC"; // Only EmailLog-backed columns can be sorted at the database level. Joined columns diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/Configuration/FormMetadataService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/Configuration/FormMetadataService.cs index 5f2600d744..3faabbe7ce 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/Configuration/FormMetadataService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/Configuration/FormMetadataService.cs @@ -272,15 +272,16 @@ private static bool IsContainerSegment(string segment) return formVersion.FormSchema; } - if (formVersion.ChefsApplicationFormGuid == null || formVersion.ChefsFormVersionGuid == null) + if (formVersion.ChefsFormVersionGuid == null) { logger.LogError("Form version {FormVersionId} is missing CHEFS configuration", formVersionId); return null; } - // Call off to CHEFS to get the schema - dynamic? formSchemaResponse = await formsApiService.GetFormDataAsync( - formVersion.ChefsApplicationFormGuid, + // ChefsApplicationFormGuid on ApplicationFormVersion can contain stale/incorrect data. + // Resolve the parent ApplicationForm via FK to get the authoritative CHEFS form GUID and API key. + dynamic? formSchemaResponse = await formsApiService.GetFormVersionDataAsync( + formVersion.ApplicationFormId, formVersion.ChefsFormVersionGuid); if (formSchemaResponse == null || formSchemaResponse?.schema == null) 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..740239b020 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/GrantManagerOnboardingApplicationProvider.cs @@ -0,0 +1,312 @@ +#nullable enable +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; +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, + IRepository applicationFormVersionRepository, + OnboardingApplicationManager onboardingApplicationManager) + : IOnboardingApplicationProvider, ITransientDependency +{ + private async Task> BuildQueryAsync(string category) + { + return (await applicationRepository.GetQueryableAsync()) + .AsNoTracking() + .Include(a => a.ApplicationForm) + .Include(a => a.ApplicationStatus) + .Include(a => a.Applicant) + .Include(a => a.ApplicantAgent) + .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 OR core-field matches + if (!string.IsNullOrWhiteSpace(filter)) + { + var effectiveMatchIds = await MergeCoreFieldGlobalMatchesAsync(category, filter, globalDynamicMatchIds); + + if (effectiveMatchIds is { Count: > 0 }) + { + var dynSet = effectiveMatchIds.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; + default: + query = ApplyCoreFieldFilter(query, cf.Name, 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 entities = await ordered + .Skip(skipCount) + .Take(maxResultCount) + .ToListAsync(); + + var coreFields = await GetMappedCoreFieldsAsync(category); + var items = entities.Select(a => ToRecord(a, coreFields)).ToList(); + + 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) + .Include(a => a.Applicant) + .Include(a => a.ApplicantAgent); + + 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 GetFormVersionsAsync(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 GetFormVersionsAsync(category); + if (versions.Count == 0) return []; + + return OnboardingCoreFieldRegistry.Fields + .Where(f => versions.Any(v => v.HasSubmissionHeaderMapping(f.Key))) + .ToList(); + } + + // 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() + .Where(f => f.Category == category) + .Select(f => f.Id) + .ToListAsync(); + + if (formIds.Count == 0) return []; + + return await (await applicationFormVersionRepository.GetQueryableAsync()) + .AsNoTracking() + .Where(v => formIds.Contains(v.ApplicationFormId)) + .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); + + switch (field.ToLowerInvariant()) + { + 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..4a27902cc8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/TenantManagement/OnboardingCoreFieldRegistry.cs @@ -0,0 +1,78 @@ +#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 (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 = + [ + 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), + + // 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/src/Unity.GrantManager.DbMigrator/GrantManagerDbMigratorModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/GrantManagerDbMigratorModule.cs index 135f89582e..c310dd2aac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/GrantManagerDbMigratorModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/GrantManagerDbMigratorModule.cs @@ -1,6 +1,7 @@ using Unity.AI; using Unity.GrantManager.EntityFrameworkCore; using Unity.Reporting; +using Unity.TenantManagement; using Volo.Abp.Autofac; using Volo.Abp.Http.Client; using Volo.Abp.Modularity; @@ -13,7 +14,10 @@ namespace Unity.GrantManager.DbMigrator; typeof(GrantManagerEntityFrameworkCoreModule), typeof(GrantManagerApplicationContractsModule), typeof(ReportingApplicationModule), // Needed to seed Reporting data - typeof(AIApplicationModule) // Needed to seed AI prompt data + typeof(AIApplicationModule), // Needed to seed AI prompt data + typeof(UnityTenantManagementApplicationModule) // Registers EncryptedTenantConnectionStringResolver — without it, + // tenant connection strings are resolved undecrypted (stock ABP + // resolver), breaking every standard-repository DB call for tenants. )] public class GrantManagerDbMigratorModule : AbpModule { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj index 2890764e6f..3d3f0566cf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj @@ -38,6 +38,7 @@ + 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 4c1c574348..ec090bfc29 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", @@ -530,6 +531,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/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/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.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..1d7e7ee4ba --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; +using Volo.Abp.TenantManagement; + +namespace Unity.GrantManager.Data; + +public partial 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 only when the value isn't valid base64 (i.e. 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); + } + } + } + + // Require at least 2 keyword hits - a single hit (e.g. "Pwd=" or "Uid=") could + // coincidentally occur at the tail of valid base64 ciphertext, right before the + // padding '='. Real connection strings always carry several key=value pairs. + private const int MinKeywordMatches = 2; + + private bool IsPlainText(string value) + { + if (ConnectionStringKeywordPattern().Matches(value).Count >= MinKeywordMatches) return true; + + try + { + encryptionService.Decrypt(value); + return false; + } + catch (FormatException) + { + // Not valid base64, so it can't be ciphertext - it's plain text. + return true; + } + // Valid base64 that failed to decrypt with a CryptographicException (e.g. wrong + // passphrase, corrupted ciphertext) is left to throw, so we don't silently overwrite + // undecryptable ciphertext as if it were plain text. + } + + [GeneratedRegex(@"(Host|Server|Port|Database|Initial Catalog|Username|User Id|Uid|Pwd|Password|Data Source)\s*=", + RegexOptions.IgnoreCase)] + private static partial Regex ConnectionStringKeywordPattern(); +} 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 78ec9708fd..0c5c6d17e4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs @@ -1,9 +1,16 @@ -using System; +using System; +using System.Text.RegularExpressions; 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.Security.Encryption; using Volo.Abp.TenantManagement; namespace Unity.GrantManager.EntityFrameworkCore; @@ -12,36 +19,101 @@ 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) { - /* 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) { - 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."); + + // Parse the stored tenant connection string to get the DB name and role credentials + 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 + ?? 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."); + + // dbName/roleName get interpolated directly into admin DDL below (Postgres + // identifiers can't be parameterized) — enforce an allowlist so a connection + // string crafted via UpdateConnectionStringsAsync can't break out of an + // identifier and inject arbitrary SQL into this admin-privileged migration path. + EnsureSafeIdentifier(dbName, "database name"); + EnsureSafeIdentifier(roleName, "role name"); + + // 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); - tenantDb.SetConnectionString(connectionString.Value); + if (!await tenantDb.CanConnectAsync()) + { + await tenantDb.GetService().CreateAsync(); + } + + // 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); + + // 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."); + EnsureSafeIdentifier(readOnlyRoleName, "readonly role name"); + + await CreateRoleIfNotExistsAsync(adminConnectionString, readOnlyRoleName, readOnlyPassword); + await GrantReadOnlyPrivilegesAsync(adminConnectionString, adminTenantConnectionString, dbName, readOnlyRoleName); + } + /* 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 +124,144 @@ await _serviceProvider .MigrateAsync(); } } + + // 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; + } + } + + // Postgres identifiers (role/database/schema names) can't be parameterized via the wire + // protocol, so every call site that interpolates one into DDL must first validate it + // against this allowlist rather than relying on escaping alone. + private static readonly Regex SafeIdentifierPattern = new("^[A-Za-z0-9_-]+$", RegexOptions.Compiled); + + private static void EnsureSafeIdentifier(string value, string fieldName) + { + if (!SafeIdentifierPattern.IsMatch(value)) + { + throw new InvalidOperationException( + $"Tenant connection string {fieldName} '{value}' contains characters that are not allowed in a PostgreSQL identifier."); + } + } + + // Passwords are used as quoted string literals (not identifiers), so unlike role/database + // names they keep a richer allowed character set — escaped here instead of allowlisted. + private static string EscapeSqlLiteral(string value) => value.Replace("'", "''"); + + 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 '{EscapeSqlLiteral(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(); + } + + 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/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 55d7557eda..fff250360e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -609,6 +609,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/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/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/ConfigurationManagement/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml index fcf22cf0eb..feb81c616c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml @@ -16,41 +16,36 @@ } @section styles { - - - @if (Model.ShowPayments) - { - - } - @if (Model.ShowNotifications) - { - - } - + + @if (Model.ShowPayments) + { + + } + @if (Model.ShowNotifications) + { + + } } @section scripts { - - - @if (Model.ShowPayments) - { - - } - - @if (Model.ShowNotifications) - { - - - } - @if (Model.ShowTags) - { - - } - @if (Model.ShowAI) - { - - } - + + @if (Model.ShowPayments) + { + + } + @if (Model.ShowNotifications) + { + + + } + @if (Model.ShowTags) + { + + } + @if (Model.ShowAI) + { + + } }
    diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css index a810f8a95c..a6efc06f1b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css @@ -33,4 +33,18 @@ margin-top: 10px; font-size: 14px; font-weight: bold; +} + +.bs-actionsbox { + margin-top: 4px; +} + +.bs-actionsbox .btn-group { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.bs-actionsbox .btn-group button { + width: auto; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js index 7118f4796c..270eafe0f8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js @@ -416,7 +416,7 @@ function highlightSelected(dropdownId,title) { } function initDropdown(dropdownId, title) { - $('#' + dropdownId).selectpicker(); + $('#' + dropdownId).selectpicker({ actionsBox: true }); $('#' + dropdownId).closest('.bootstrap-select').find('.btn .filter-option-inner-inner').html(title); $('#' + dropdownId).closest('.bootstrap-select').find('.btn .filter-option').addClass('button-align-center'); } 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/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); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/BackgroundJobsSettingGroup/BackgroundJobScriptBundleContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/BackgroundJobsSettingGroup/BackgroundJobScriptBundleContributor.cs index e4cd420b85..d435f7735e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/BackgroundJobsSettingGroup/BackgroundJobScriptBundleContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/BackgroundJobsSettingGroup/BackgroundJobScriptBundleContributor.cs @@ -7,6 +7,6 @@ public class BackgroundJobsScriptBundleContributor : BundleContributor { public override void ConfigureBundle(BundleConfigurationContext context) { - context.Files.AddIfNotContains("/Views/Settings/BackgroundJobsGroup/Default.js"); + } } 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'); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json index 20f1e373ee..67af0468e2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json @@ -17,7 +17,7 @@ "datatables.net-staterestore": "~1.4.2", "datatables.net-staterestore-dt": "~1.4.2", "dompurify": "~3.4.1", - "echarts": "~6.0.0", + "echarts": "~6.1.0", "formiojs": "4.21.7", "handlebars": "~4.7.9", "jquery-maskmoney": "~3.0.2", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock index 9aba3bfb98..1876c79a32 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock @@ -698,13 +698,13 @@ dragula@^3.7.3: contra "1.9.4" crossvent "1.5.5" -echarts@~6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz" - integrity sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ== +echarts@~6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/echarts/-/echarts-6.1.0.tgz#ae0f68590f5ebbd728d900907c27acde7c5456d1" + integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA== dependencies: tslib "2.3.0" - zrender "6.0.0" + zrender "6.1.0" escape-string-regexp@^4.0.0: version "4.0.0" @@ -1516,9 +1516,9 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zrender@6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz" - integrity sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg== +zrender@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/zrender/-/zrender-6.1.0.tgz#c3ef06e6426d5c28865b7183035680d685e00136" + integrity sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ== dependencies: tslib "2.3.0" diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Events/ChefsEventSubscriptionServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Events/ChefsEventSubscriptionServiceTests.cs index 32ba5366fa..06227f065d 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Events/ChefsEventSubscriptionServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Events/ChefsEventSubscriptionServiceTests.cs @@ -104,6 +104,11 @@ Task IFormsApiService.GetForm(Guid? formId, string chefsApplicationForm { throw new NotImplementedException(); } + + Task IFormsApiService.GetFormVersionDataAsync(Guid applicationFormId, string chefsFormVersionId) + { + throw new NotImplementedException(); + } } public class IntakeFormSubmissionMapperMock : IIntakeFormSubmissionMapper 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/Notifications/NotificationListAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Notifications/NotificationListAppServiceTests.cs index 45b7b47131..13939ad1c4 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Notifications/NotificationListAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Notifications/NotificationListAppServiceTests.cs @@ -58,6 +58,41 @@ public async Task GetListAsync_Should_Return_Notification_Joined_With_Applicatio result.TotalCount.ShouldBeGreaterThanOrEqualTo(1); } + [Theory] + [Trait("Category", "Integration")] + [InlineData(EmailType.Manual, "Manual")] + [InlineData(EmailType.DateBased, "Scheduled")] + [InlineData(EmailType.EventBased, "Event Based")] + [InlineData(EmailType.Delayed, "Delayed")] + public async Task GetListAsync_Should_Label_EmailType_Per_Taxonomy(EmailType emailType, string expectedLabel) + { + SetFeatureEnabled("Unity.Notifications", true); + + // Arrange: Manual, Scheduled (DateBased), Event Based (EventBased), Delayed + var application = await _applicationRepository.GetAsync(GrantManagerTestData.Application1_Id); + var emailLog = await _emailLogsRepository.InsertAsync(new EmailLog + { + ApplicationId = application.Id, + ApplicantId = application.ApplicantId, + Subject = "Email Type Label Test", + ToAddress = "applicant@example.com", + FromAddress = "noreply@gov.bc.ca", + Status = "Sent", + Recipient = RecipientType.Internal, + EmailType = emailType + }, autoSave: true); + + // Act + var result = await _notificationListAppService.GetListAsync( + new PagedAndSortedResultRequestDto { MaxResultCount = 1000 }); + + // Assert + var row = result.Items.FirstOrDefault(i => i.Id == emailLog.Id); + row.ShouldNotBeNull(); + row.EmailType.ShouldBe(emailType); + row.EmailTypeText.ShouldBe(expectedLabel); + } + [Fact] [Trait("Category", "Integration")] public async Task GetListAsync_Should_Return_Row_Even_When_Application_Missing() 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..0ea0adf18a --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TenantManagement/GrantManagerOnboardingApplicationProviderTests.cs @@ -0,0 +1,491 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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 _applicationFormVersionRepository; + private readonly IRepository _applicationStatusRepository; + private readonly IRepository _applicantRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public GrantManagerOnboardingApplicationProviderTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + _provider = GetRequiredService(); + _applicationRepository = GetRequiredService>(); + _applicationFormRepository = GetRequiredService>(); + _applicationFormVersionRepository = GetRequiredService>(); + _applicationStatusRepository = GetRequiredService>(); + _applicantRepository = 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 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 + { + InternalStatus = internalStatus, + ExternalStatus = internalStatus, + 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? applicantId = null, string? projectName = null, decimal? requestedAmount = null) => + await _applicationRepository.InsertAsync(new Application + { + ApplicationFormId = applicationFormId, + ApplicationStatusId = applicationStatusId, + ApplicantId = applicantId ?? GrantManagerTestData.Applicant1_Id, + ReferenceNo = referenceNo, + SubmissionDate = submissionDate, + ProjectName = projectName ?? string.Empty, + RequestedAmount = requestedAmount ?? 0 + }, 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); + } + + [Fact] + [Trait("Category", "Integration")] + 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); + 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([oldUnpublished, currentPublished], ignoreOrder: true); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetFormVersionIdsAsync_FormWithNoVersions_IsExcluded() + { + var category = $"OnboardingTest-{Guid.NewGuid()}"; + await CreateFormAsync(category); + + 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_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: """{"TotalScore": "totalScore"}"""); + + using var uow = _unitOfWorkManager.Begin(); + var result = await _provider.GetMappedCoreFieldColumnsAsync(category); + + 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() + { + 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); + } +} diff --git a/applications/Unity.Tools/Unity.NginxData/404.html b/applications/Unity.Tools/Unity.NginxData/404.html new file mode 100644 index 0000000000..38815d583c --- /dev/null +++ b/applications/Unity.Tools/Unity.NginxData/404.html @@ -0,0 +1,64 @@ + + + + + + Page Not Found + + + +
    +
    +

    Page Not Found

    +

    The page you requested is not available.

    +
    + + diff --git a/applications/Unity.Tools/Unity.NginxData/50x.html b/applications/Unity.Tools/Unity.NginxData/50x.html new file mode 100644 index 0000000000..65c6158c15 --- /dev/null +++ b/applications/Unity.Tools/Unity.NginxData/50x.html @@ -0,0 +1,64 @@ + + + + + + Service Unavailable + + + +
    +
    +

    Service Unavailable

    +

    Internal errors, bad gateway, or timeout on this service.

    +
    + + diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Bold.woff b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Bold.woff new file mode 100644 index 0000000000..0e2f8e3bd1 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Bold.woff differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Bold.woff2 b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Bold.woff2 new file mode 100644 index 0000000000..3db78138fb Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Bold.woff2 differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-BoldItalic.woff b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-BoldItalic.woff new file mode 100644 index 0000000000..e466626bc6 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-BoldItalic.woff differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-BoldItalic.woff2 b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-BoldItalic.woff2 new file mode 100644 index 0000000000..ac32077e5b Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-BoldItalic.woff2 differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Italic.woff b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Italic.woff new file mode 100644 index 0000000000..52af7b65f6 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Italic.woff differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Italic.woff2 b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Italic.woff2 new file mode 100644 index 0000000000..9468cec3a8 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Italic.woff2 differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Light.woff b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Light.woff new file mode 100644 index 0000000000..02b3e85765 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Light.woff differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Light.woff2 b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Light.woff2 new file mode 100644 index 0000000000..29a4731501 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Light.woff2 differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-LightItalic.woff b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-LightItalic.woff new file mode 100644 index 0000000000..e85ec2c993 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-LightItalic.woff differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-LightItalic.woff2 b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-LightItalic.woff2 new file mode 100644 index 0000000000..b2acc22199 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-LightItalic.woff2 differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Regular.woff b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Regular.woff new file mode 100644 index 0000000000..58478e8c2c Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Regular.woff differ diff --git a/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Regular.woff2 b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Regular.woff2 new file mode 100644 index 0000000000..7011f82b30 Binary files /dev/null and b/applications/Unity.Tools/Unity.NginxData/fonts/BCSans/BCSans-Regular.woff2 differ diff --git a/applications/Unity.Tools/Unity.NginxData/grants.html b/applications/Unity.Tools/Unity.NginxData/grants.html deleted file mode 100644 index 46330ef6ca..0000000000 --- a/applications/Unity.Tools/Unity.NginxData/grants.html +++ /dev/null @@ -1,354 +0,0 @@ - - - - - Enterprise Grant - - - - - - -
    - - - - - -
    grants.gov.bc.ca
    - - - - - -
    - - - - -
    - -
    - -
    - - - - - - - - \ No newline at end of file diff --git a/applications/Unity.Tools/Unity.NginxData/index.html b/applications/Unity.Tools/Unity.NginxData/index.html index d75313de84..c48d4a91a3 100644 --- a/applications/Unity.Tools/Unity.NginxData/index.html +++ b/applications/Unity.Tools/Unity.NginxData/index.html @@ -3,8 +3,44 @@ Unity Grant Management - Contact Information - +