diff --git a/.gitignore b/.gitignore index 79259fa81..841973049 100644 --- a/.gitignore +++ b/.gitignore @@ -115,4 +115,5 @@ appsettings.json /applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.secrets.json /applications/Orchestrator -*.env \ No newline at end of file +*.env +/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package-lock.json \ No newline at end of file diff --git a/applications/Unity.AutoUI/package-lock.json b/applications/Unity.AutoUI/package-lock.json index 6a889f0cc..badb1ccea 100644 --- a/applications/Unity.AutoUI/package-lock.json +++ b/applications/Unity.AutoUI/package-lock.json @@ -13,9 +13,9 @@ } }, "node_modules/@cypress/request": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz", - "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -32,7 +32,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.14.1", + "qs": "~6.14.1", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", @@ -64,9 +64,9 @@ } }, "node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "dev": true, "license": "MIT", "optional": true, @@ -591,18 +591,18 @@ } }, "node_modules/cypress": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.8.1.tgz", - "integrity": "sha512-ogc62stTQGh1395ipKxfCE5hQuSApTzeH5e0d9U6m7wYO9HQeCpgnkYtBtd0MbkN2Fnch5Od2mX9u4hoTlrH4Q==", + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.9.0.tgz", + "integrity": "sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@cypress/request": "^3.0.9", + "@cypress/request": "^3.0.10", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", - "@types/tmp": "^0.2.6", + "@types/tmp": "^0.2.3", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", @@ -626,7 +626,7 @@ "hasha": "5.2.2", "is-installed-globally": "~0.4.0", "listr2": "^3.8.3", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "log-symbols": "^4.0.0", "minimist": "^1.2.8", "ospath": "^1.2.2", @@ -636,7 +636,7 @@ "request-progress": "^3.0.0", "supports-color": "^8.1.1", "systeminformation": "^5.27.14", - "tmp": "~0.2.6", + "tmp": "~0.2.4", "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" @@ -1359,9 +1359,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -1658,7 +1658,7 @@ "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1942,9 +1942,9 @@ } }, "node_modules/systeminformation": { - "version": "5.27.15", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.15.tgz", - "integrity": "sha512-5JhtiApuEbeJB1Le/3O1nwz/GTl74ABjtYkI4D5rJk9IIrX4Q2KLs0B5yp1FV0oNtQCeDzdMwks2umImIgm6Og==", + "version": "5.30.5", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.5.tgz", + "integrity": "sha512-DpWmpCckhwR3hG+6udb6/aQB7PpiqVnvSljrjbKxNSvTRsGsg7NVE3/vouoYf96xgwMxXFKcS4Ux+cnkFwYM7A==", "dev": true, "license": "MIT", "os": [ @@ -2006,8 +2006,8 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", diff --git a/applications/Unity.GrantManager/Directory.Build.props b/applications/Unity.GrantManager/Directory.Build.props new file mode 100644 index 000000000..87aa7c3ae --- /dev/null +++ b/applications/Unity.GrantManager/Directory.Build.props @@ -0,0 +1,17 @@ + + + + + + + + $(NoWarn);NU1701;MSB3277 + + + + + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/common.props b/applications/Unity.GrantManager/common.props index 7e89c3a06..99989f95b 100644 --- a/applications/Unity.GrantManager/common.props +++ b/applications/Unity.GrantManager/common.props @@ -2,7 +2,7 @@ latest 1.0.0 - $(NoWarn);CS1591 + $(NoWarn);CS1591;MSB3277 app diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetSectionDto.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetSectionDto.cs index 39917c435..0568ad87b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetSectionDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetSectionDto.cs @@ -9,11 +9,8 @@ namespace Unity.Flex.Scoresheets public class ScoresheetSectionDto : ExtensibleEntityDto { public virtual string Name { get; private set; } = string.Empty; - public virtual uint Order { get; private set; } - public virtual Guid ScoresheetId { get; } - + public virtual uint Order { get; private set;} = 0; + public virtual Guid ScoresheetId { get; } public virtual Collection Fields { get; private set; } = []; - - } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/ScoresheetInstance.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/ScoresheetInstance.cs index 616db1b56..6d5d73a0f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/ScoresheetInstance.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/ScoresheetInstance.cs @@ -20,7 +20,7 @@ public class ScoresheetInstance : FullAuditedAggregateRoot, IMultiTenant, // Correlation public virtual Guid CorrelationId { get; private set; } - public virtual string CorrelationProvider { get; private set; } = string.Empty; + public virtual string CorrelationProvider { get; private set; } public Guid? TenantId { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Services/WorksheetsManager.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Services/WorksheetsManager.cs index 6dda9ec40..9fb9ec6ee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Services/WorksheetsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Services/WorksheetsManager.cs @@ -168,7 +168,7 @@ private async Task CreateNewWorksheetInstanceAsync(IWorksheet var newWorksheetInstances = new List<(Worksheet, WorksheetInstance)>(); // naming convention custom_worksheetname_fieldname - foreach (var (fieldName, chefsPropertyName, value) in eventData.CustomFields) + foreach (var (fieldName, _, _) in eventData.CustomFields) { var split = fieldName.Split('_', StringSplitOptions.RemoveEmptyEntries); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs index 582883108..8c8acbd6e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs @@ -41,7 +41,7 @@ internal static List ExtractCustomColumnsValues(DataGri CurrentValueId = Guid.Empty, Definition = GetDefaultDefinition(column.Type), Enabled = true, - Type = (CustomFieldType)Enum.Parse(typeof(CustomFieldType), column.Type), + Type = Enum.Parse(column.Type), UiAnchor = string.Empty, Order = rowNumber }); @@ -52,7 +52,7 @@ internal static List ExtractCustomColumnsValues(DataGri internal static string GetDefaultDefinition(string type) { - return DefinitionResolver.Resolve((CustomFieldType)Enum.Parse(typeof(CustomFieldType), type), null); + return DefinitionResolver.Resolve(Enum.Parse(type), null); } internal static string? GetCurrentValueAndTransform(DataGridRow? dataRow, DataGridDefinitionColumn column) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/QuestionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/QuestionModal.cshtml.cs index 3fc412033..a9d29ca11 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/QuestionModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/QuestionModal.cshtml.cs @@ -22,8 +22,7 @@ public QuestionModalModel(IQuestionAppService questionAppService, IScoresheetApp { _questionAppService = questionAppService; _scoresheetAppService = scoresheetAppService; - QuestionTypeOptionsList = Enum.GetValues(typeof(QuestionType)) - .Cast() + QuestionTypeOptionsList = Enum.GetValues() .Select(qt => new SelectListItem { Value = ((int)qt).ToString(), diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertCustomFieldModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertCustomFieldModal.cshtml.cs index 65d093025..9a1c7e55a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertCustomFieldModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertCustomFieldModal.cshtml.cs @@ -63,7 +63,7 @@ public async Task OnGetAsync(Guid worksheetId, Guid sectionId, Guid fieldId, str WorksheetId = worksheetId; SectionId = sectionId; FieldTypes = GetAvailableFieldTypes(); - UpsertAction = (WorksheetUpsertAction)Enum.Parse(typeof(WorksheetUpsertAction), actionType); + UpsertAction = Enum.Parse(actionType); FieldType = "Text"; if (UpsertAction == WorksheetUpsertAction.Update) @@ -118,7 +118,7 @@ private async Task InsertCustomField() Definition = ExtractDefinition(), Label = Label!, Key = Key!, - Type = (CustomFieldType)Enum.Parse(typeof(CustomFieldType), FieldType!) + Type = Enum.Parse(FieldType!) }); } @@ -129,7 +129,7 @@ private async Task UpdateCustomField() Definition = ExtractDefinition(), Label = Label!, Key = Key!, - Type = (CustomFieldType)Enum.Parse(typeof(CustomFieldType), FieldType!) + Type = Enum.Parse(FieldType!) }); } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml.cs index ec908c8f5..c4d381bdc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml.cs @@ -38,7 +38,7 @@ public async Task OnGetAsync(Guid worksheetId, Guid sectionId, string actionType { WorksheetId = worksheetId; SectionId = sectionId; - UpsertAction = (WorksheetUpsertAction)Enum.Parse(typeof(WorksheetUpsertAction), actionType); + UpsertAction = Enum.Parse(actionType); if (UpsertAction == WorksheetUpsertAction.Update) { diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertWorksheetModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertWorksheetModal.cshtml.cs index f6640d8ba..9d0c63033 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertWorksheetModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertWorksheetModal.cshtml.cs @@ -32,12 +32,12 @@ public class UpsertWorksheetModalModel(IWorksheetAppService worksheetAppService) public async Task OnGetAsync(Guid worksheetId, string actionType) { - UpsertAction = (WorksheetUpsertAction)Enum.Parse(typeof(WorksheetUpsertAction), actionType); + UpsertAction = Enum.Parse(actionType); if (UpsertAction == WorksheetUpsertAction.Update) { WorksheetDto worksheetDto = await worksheetAppService.GetAsync(worksheetId); - UpsertAction = (WorksheetUpsertAction)Enum.Parse(typeof(WorksheetUpsertAction), actionType); + UpsertAction = Enum.Parse(actionType); Name = worksheetDto.Name; WorksheetId = worksheetDto.Id; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/BCAddressWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/BCAddressWidget/Default.css index 19a76f130..5a7c055fa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/BCAddressWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/BCAddressWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* BC Address Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyDefinitionWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyDefinitionWidget/Default.css index 19a76f130..2d195e17e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyDefinitionWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyDefinitionWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Currency Definition Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.css index 19a76f130..5559c326c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Currency Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomFieldDefinitionWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomFieldDefinitionWidget/Default.css index 19a76f130..1906526e9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomFieldDefinitionWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomFieldDefinitionWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Custom Field Definition Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomTabWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomTabWidget/Default.css index 19a76f130..904a4c9ff 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomTabWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CustomTabWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Custom Tab Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs index 9e1f6383b..6f7b01918 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs @@ -13,7 +13,6 @@ public class DataGridViewModel : WorksheetViewModelBase public DataGridDefinitionSummaryOption SummaryOption { get; set; } public DataGridViewSummary Summary { get; set; } = new DataGridViewSummary(); public Guid WorksheetInstanceId { get; set; } - public Guid WorksheetId { get; set; } public string UiAnchor { get; set; } = string.Empty; public DataGridViewModel() : base() diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs index 64fd3f0a2..9c90de0d2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs @@ -335,7 +335,7 @@ private static List GenerateDynamicRowPlaceholder() private static DataGridDefinitionSummaryOption ConvertSummaryOption(DataGridDefinition dataGridDefinition) { - return (DataGridDefinitionSummaryOption)Enum.Parse(typeof(DataGridDefinitionSummaryOption), dataGridDefinition.SummaryOption); + return Enum.Parse(dataGridDefinition.SummaryOption); } private static DataGridViewSummary GenerateDynamicPlaceholderSummary() diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js index 97a82e4d3..69d90ae04 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js @@ -105,37 +105,36 @@ $(function () { handleEditDatagridRowModalResult(response); }); + // Function to calculate sum for a specific column + function calculateColumnSum(table, columnIndex) { + let total = 0; + table.column(columnIndex).data().each(function (value) { + // Remove currency symbols and commas for numeric check + let cleanedValue = value.replace(/[^\d.-]/g, ''); + if (isNumeric(cleanedValue)) { + total += parseFloat(cleanedValue); + } + }); + return total; + } + // Function to update totals function updateTotals(table, fieldId) { // Iterate through each input field that has the id pattern 'total-{key}' $('#summary-' + fieldId + ' input[id^="total-"]').each(function () { let inputId = $(this).attr('id'); let key = inputId.replace('total-', ''); - let total = 0; - let headerFound = false; - + // Find the corresponding column in the DataTable by matching the key - table.columns().header().each(function (header, index) { - if ($(header).text() === key) { - headerFound = true; - // Sum up all numeric values in the column - table.column(index).data().each(function (value, rowIndex) { - // Remove currency symbols and commas for numeric check - let cleanedValue = value.replace(/[^\d.-]/g, ''); - - if (isNumeric(cleanedValue)) { - total += parseFloat(cleanedValue); - } - }); - } - }); - - // Update the input field with the calculated total only if the header is found - if (headerFound) { + let columnIndex = getColumnIndex(table, key); + + if (columnIndex !== -1) { + let total = calculateColumnSum(table, columnIndex); + + // Update the input field with the calculated total if ($(this).data('field-type') === 'Currency') { $(this).val(formatCurrency(total)); - } - else { + } else { $(this).val(total); } } @@ -278,6 +277,25 @@ $(function () { return availableOptions; } + // Function to configure action buttons for a table cell + function configureActionButtonForCell(cell) { + cell.innerHTML = getEditRowButtonTemplate(); // Add edit button to each cell + + // Attach click event handler to the newly added button + $(cell).find('.row-edit-btn').on('click', function () { + let button = this; // `this` refers to the button element + editDataRow(button); + }); + } + + // Function to setup the actions column + function setupActionsColumn(table, columnIndex) { + table.column(columnIndex).header().innerHTML = 'Actions'; // Update column header if needed + table.column(columnIndex).nodes().each(function (cell) { + configureActionButtonForCell(cell); + }); + } + function configureTable(table, fieldId) { // Move buttons to custom container table.buttons().container().prependTo(`#btn-container-${fieldId}`); @@ -285,16 +303,7 @@ $(function () { // Add edit buttons to the last column (Actions) table.columns().every(function (index) { if (index === table.columns().count() - 1) { // Check if it is the last column - table.column(index).header().innerHTML = 'Actions'; // Update column header if needed - table.column(index).nodes().each(function (cell) { - cell.innerHTML = getEditRowButtonTemplate(); // Add edit button to each cell - - // Attach click event handler to the newly added button - $(cell).find('.row-edit-btn').on('click', function () { - let button = this; // `this` refers to the button element - editDataRow(button); - }); - }); + setupActionsColumn(table, index); } }); } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.css index 19a76f130..3fb865e53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Date Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/NumericDefinitionWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/NumericDefinitionWidget/Default.css index 19a76f130..20aaf7639 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/NumericDefinitionWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/NumericDefinitionWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Numeric Definition Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionYesNoDefinitionWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionYesNoDefinitionWidget/Default.css index 19a76f130..d1beba483 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionYesNoDefinitionWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionYesNoDefinitionWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Question Yes No Definition Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.css index 19a76f130..196feb1d2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Select List Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextDefinitionWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextDefinitionWidget/Default.css index 19a76f130..437d05f89 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextDefinitionWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextDefinitionWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Text Definition Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.css index 19a76f130..fe5d6b0f3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.css @@ -1 +1 @@ - \ No newline at end of file +/* Yes No Widget Styles - Placeholder file required by the component structure */ \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml index f5a488198..b3d420df5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml @@ -104,12 +104,7 @@ static string ConvertUserFriendlyFormat(DateTime? dateTime) { return dateTime == null ? DefaultEmptyValue : dateTime.Value.ToUniversalTime().ToString(DateTimeFormat); - } - - static string ConvertUserFriendlyFormat(DateTimeOffset? dateTime) - { - return dateTime == null ? DefaultEmptyValue : dateTime.Value.UtcDateTime.ToString(DateTimeFormat); - } + } static string ConvertUserFriendlyFormat(string value) { diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml.cs index ffc018bd5..eb9fd5b53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml.cs @@ -45,12 +45,9 @@ public virtual async Task OnGetAsync(Guid id) IsEditCurrentUser = CurrentUser.Id == id; var userRoleNames = (await IdentityUserAppService.GetRolesAsync(UserInfo.Id)).Items.Select(r => r.Name).ToList(); - foreach (var role in Roles) + foreach (var role in Roles.Where(r => userRoleNames.Contains(r.Name))) { - if (userRoleNames.Contains(role.Name)) - { - role.IsAssigned = true; - } + role.IsAssigned = true; } Detail = ObjectMapper.Map(user); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs new file mode 100644 index 000000000..65eacb335 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs @@ -0,0 +1,308 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Linq.Expressions; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Unity.Modules.Shared.Utils; +using Unity.Notifications.Emails; +using Unity.Notifications.Events; +using Unity.Notifications.Integrations.Ches; +using Unity.Notifications.Integrations.RabbitMQ; +using Unity.Notifications.Settings; +using Volo.Abp; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Services; +using Volo.Abp.Settings; + + +namespace Unity.Notifications.EmailNotifications +{ + /// + /// Domain manager for email notification operations + /// + public class EmailNotificationManager( + IEmailLogsRepository emailLogsRepository, + IChesClientService chesClientService, + EmailQueueService emailQueueService, + EmailAttachmentService emailAttachmentService, + ISettingProvider settingProvider) : DomainService, IEmailNotificationManager + { + public async Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + { + return await CreateEmailLogAsync(emailTo, body, subject, applicationId, emailFrom, EmailStatus.Initialized, emailTemplateName, emailCC, emailBCC); + } + + [RemoteService(false)] + public async Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + { + if (string.IsNullOrEmpty(emailTo)) + { + return null; + } + var emailObject = await GetEmailObjectAsync(emailTo, body, subject, emailFrom, "html", emailTemplateName, emailCC, emailBCC); + EmailLog emailLog = new(); + emailLog = UpdateMappedEmailLog(emailLog, emailObject); + emailLog.ApplicationId = applicationId; + emailLog.Status = status ?? EmailStatus.Initialized; + + // When being called here the current tenant is in context - verified by looking at the tenant id + EmailLog loggedEmail = await emailLogsRepository.InsertAsync(emailLog, autoSave: true); + return loggedEmail; + } + + public async Task UpdateEmailLogAsync(Guid emailId, string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + { + if (string.IsNullOrEmpty(emailTo)) + { + return null; + } + + var emailObject = await GetEmailObjectAsync(emailTo, body, subject, emailFrom, "html", emailTemplateName, emailCC, emailBCC); + EmailLog emailLog = await emailLogsRepository.GetAsync(emailId); + emailLog = UpdateMappedEmailLog(emailLog, emailObject); + emailLog.ApplicationId = applicationId; + emailLog.Id = emailId; + emailLog.Status = status ?? EmailStatus.Initialized; + + // When being called here the current tenant is in context - verified by looking at the tenant id + EmailLog loggedEmail = await emailLogsRepository.UpdateAsync(emailLog, autoSave: true); + return loggedEmail; + } + + public async Task GetEmailLogByIdAsync(Guid id) + { + try + { + return await emailLogsRepository.GetAsync(id); + } + catch (EntityNotFoundException ex) + { + Logger.LogError(ex, "Entity not found for Email Log. Tenant context may be incorrect: {ExceptionMessage}", ex.Message); + return null; + } + } + + public async Task DeleteEmailLogAsync(Guid id) + { + await emailLogsRepository.DeleteAsync(id); + } + + /// + /// Send Email Notification + /// + /// The email address to send to + /// The body of the email + /// Subject Message + /// From Email Address + /// Type of body email: html or text + /// Template name for the email + /// CC email addresses + /// BCC email addresses + /// HttpResponseMessage indicating the result of the operation + public async Task SendEmailAsync(string emailTo, string body, string subject, string? emailFrom, string? emailBodyType, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + { + try + { + if (string.IsNullOrEmpty(emailTo)) + { + Logger.LogError("EmailNotificationManager->SendEmailAsync: The 'emailTo' parameter is null or empty."); + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("'emailTo' cannot be null or empty.") + }; + } + + // Send the email using the CHES client service + var emailObject = await GetEmailObjectAsync(emailTo, body, subject, emailFrom, emailBodyType, emailTemplateName, emailCC, emailBCC); + var response = await chesClientService.SendAsync(emailObject); + + // Assuming SendAsync returns a HttpResponseMessage or equivalent: + return response; + } + catch (Exception ex) + { + Logger.LogError(ex, "EmailNotificationManager->SendEmailAsync: Exception occurred while sending email."); + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent($"An exception occurred while sending the email: {ex.Message}") + }; + } + } + + /// + /// Send Email Notification from EmailLog (with S3 attachments support) + /// + /// The email log containing email details + /// HttpResponseMessage indicating the result of the operation + [RemoteService(false)] + public async Task SendEmailAsync(EmailLog emailLog) + { + try + { + if (emailLog == null) + { + Logger.LogError("EmailNotificationManager->SendEmailAsync: The 'emailLog' parameter is null."); + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("'emailLog' cannot be null.") + }; + } + + if (string.IsNullOrEmpty(emailLog.ToAddress)) + { + Logger.LogError("EmailNotificationManager->SendEmailAsync: The 'emailLog.ToAddress' parameter is null or empty."); + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("'emailLog.ToAddress' cannot be null or empty.") + }; + } + + // Build email object with attachments from S3 + var emailObject = await BuildEmailObjectWithAttachmentsAsync(emailLog); + + // Send via CHES + var response = await chesClientService.SendAsync(emailObject); + + return response; + } + catch (Exception ex) + { + Logger.LogError(ex, "EmailNotificationManager->SendEmailAsync: Exception occurred while sending email for EmailLog {EmailId}.", emailLog?.Id); + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent($"An exception occurred while sending the email: {ex.Message}") + }; + } + } + + /// + /// Send Email To Queue + /// + /// The email log to send to queue + public async Task QueueEmailAsync(EmailLog emailLog) + { + EmailNotificationEvent emailNotificationEvent = new() + { + Id = emailLog.Id, + TenantId = emailLog.TenantId, + RetryAttempts = emailLog.RetryAttempts + }; + await emailQueueService.SendToEmailEventQueueAsync(emailNotificationEvent); + } + + public async Task GetPendingEmailsCountAsync() + { + var dbNow = DateTime.UtcNow; + + // Create the expression to filter the email logs + Expression> filter = x => + (x.Status == EmailStatus.Sent && x.ChesResponse == null) || + (x.Status == EmailStatus.Initialized && x.CreationTime.AddMinutes(10) < dbNow); + + // Fetch all email logs and apply the filter using LINQ + var allEmailLogs = await emailLogsRepository.GetListAsync(); + var emailLogs = allEmailLogs.Where(filter.Compile()).ToList(); + + // Ensure we're returning 0 if no logs are found + return emailLogs?.Count ?? 0; + } + + public async Task BuildEmailObjectWithAttachmentsAsync(EmailLog emailLog) + { + // Get base email object (without attachments) + var emailObject = await GetEmailObjectAsync( + emailLog.ToAddress, + emailLog.Body, + emailLog.Subject, + emailLog.FromAddress, + emailLog.BodyType, + emailLog.TemplateName, + emailLog.CC, + emailLog.BCC); + + // Retrieve attachments from S3 + var attachments = await emailAttachmentService.GetAttachmentsAsync(emailLog.Id); + + if (attachments.Count != 0) + { + var attachmentList = new List(); + + foreach (var attachment in attachments) + { + byte[]? content = await emailAttachmentService.DownloadFromS3Async(attachment.S3ObjectKey); + if (content != null) + { + attachmentList.Add(new + { + content = Convert.ToBase64String(content), // Convert to Base64 for CHES + contentType = attachment.ContentType, + encoding = "base64", + filename = attachment.FileName + }); + } + } + + var emailObjectDictionary = (IDictionary)emailObject; + emailObjectDictionary["attachments"] = attachmentList.ToArray(); + } + + return emailObject; + } + + protected virtual async Task GetEmailObjectAsync( + string emailTo, + string body, + string subject, + string? emailFrom, + string? emailBodyType, + string? emailTemplateName, + string? emailCC = null, + string? emailBCC = null) + { + var toList = emailTo.ParseEmailList() ?? []; + var ccList = emailCC.ParseEmailList(); + var bccList = emailBCC.ParseEmailList(); + + var defaultFromAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); + + dynamic emailObject = new ExpandoObject(); + var emailObjectDictionary = (IDictionary)emailObject; + + emailObjectDictionary["body"] = body; + emailObjectDictionary["bodyType"] = emailBodyType ?? "text"; + emailObjectDictionary["cc"] = ccList; + emailObjectDictionary["bcc"] = bccList; + emailObjectDictionary["encoding"] = "utf-8"; + emailObjectDictionary["from"] = emailFrom ?? defaultFromAddress ?? "NoReply@gov.bc.ca"; + emailObjectDictionary["priority"] = "normal"; + emailObjectDictionary["subject"] = subject; + emailObjectDictionary["tag"] = "tag"; + emailObjectDictionary["to"] = toList; + emailObjectDictionary["templateName"] = emailTemplateName; + + return emailObject; + } + + protected virtual EmailLog UpdateMappedEmailLog(EmailLog emailLog, dynamic emailDynamicObject) + { + emailLog.Body = emailDynamicObject.body; + emailLog.Subject = emailDynamicObject.subject; + emailLog.BodyType = emailDynamicObject.bodyType; + emailLog.FromAddress = emailDynamicObject.from; + emailLog.ToAddress = string.Join(",", emailDynamicObject.to); + emailLog.CC = emailDynamicObject.cc != null ? string.Join(",", (IEnumerable)emailDynamicObject.cc) : string.Empty; + emailLog.BCC = emailDynamicObject.bcc != null ? string.Join(",", (IEnumerable)emailDynamicObject.bcc) : string.Empty; + emailLog.TemplateName = emailDynamicObject.templateName; + return emailLog; + } + + public async Task> GetEmailLogsByApplicationIdAsync(Guid applicationId) + { + return await emailLogsRepository.GetByApplicationIdAsync(applicationId); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index cce83d66e..ffeb49a21 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -2,23 +2,16 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; -using System.Dynamic; using System.Linq; -using System.Linq.Expressions; using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Unity.Modules.Shared.Utils; using Unity.Notifications.Emails; -using Unity.Notifications.Events; -using Unity.Notifications.Integrations.Ches; -using Unity.Notifications.Integrations.RabbitMQ; using Unity.Notifications.Permissions; using Unity.Notifications.Settings; using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Entities; using Volo.Abp.Features; using Volo.Abp.SettingManagement; using Microsoft.AspNetCore.Http; @@ -29,82 +22,39 @@ namespace Unity.Notifications.EmailNotifications; [Dependency(ReplaceServices = false)] [ExposeServices(typeof(EmailNotificationService), typeof(IEmailNotificationService))] -#pragma warning disable S107 // Methods should not have too many parameters public class EmailNotificationService( INotificationsAppService notificationAppService, - IEmailLogsRepository emailLogsRepository, - IChesClientService chesClientService, - EmailQueueService emailQueueService, + EmailNotificationManager emailNotificationManager, IExternalUserLookupServiceProvider externalUserLookupServiceProvider, ISettingManager settingManager, - IFeatureChecker featureChecker, IHttpContextAccessor httpContextAccessor, - EmailAttachmentService emailAttachmentService) : ApplicationService, IEmailNotificationService -#pragma warning restore S107 // Methods should not have too many parameters + IFeatureChecker featureChecker) : ApplicationService, IEmailNotificationService { public async Task DeleteEmail(Guid id) { - await emailLogsRepository.DeleteAsync(id); - } + await emailNotificationManager.DeleteEmailLogAsync(id); + } public async Task GetEmailsChesWithNoResponseCountAsync() { - var dbNow = DateTime.UtcNow; - - // Create the expression to filter the email logs - Expression> filter = x => - (x.Status == EmailStatus.Sent && x.ChesResponse == null) || - (x.Status == EmailStatus.Initialized && x.CreationTime.AddMinutes(10) < dbNow); - - // Fetch all email logs and apply the filter using LINQ - var allEmailLogs = await emailLogsRepository.GetListAsync(); - var emailLogs = allEmailLogs.Where(filter.Compile()).ToList(); - - // Ensure we're returning 0 if no logs are found - return emailLogs?.Count ?? 0; + return await emailNotificationManager.GetPendingEmailsCountAsync(); } public async Task UpdateEmailLog(Guid emailId, string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) { - if (string.IsNullOrEmpty(emailTo)) - { - return null; - } - - var emailObject = await GetEmailObjectAsync(emailTo, body, subject, emailFrom, "html", emailTemplateName, emailCC, emailBCC); - EmailLog emailLog = await emailLogsRepository.GetAsync(emailId); - emailLog = UpdateMappedEmailLog(emailLog, emailObject); - emailLog.ApplicationId = applicationId; - emailLog.Id = emailId; - emailLog.Status = status ?? EmailStatus.Initialized; - - // When being called here the current tenant is in context - verified by looking at the tenant id - EmailLog loggedEmail = await emailLogsRepository.UpdateAsync(emailLog, autoSave: true); - return loggedEmail; + return await emailNotificationManager.UpdateEmailLogAsync(emailId, emailTo, body, subject, applicationId, emailFrom, status, emailTemplateName, emailCC, emailBCC); } public async Task InitializeEmailLog(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) { - return await InitializeEmailLog(emailTo, body, subject, applicationId, emailFrom, EmailStatus.Initialized, emailTemplateName, emailCC, emailBCC); + return await emailNotificationManager.CreateEmailLogAsync(emailTo, body, subject, applicationId, emailFrom, emailTemplateName, emailCC, emailBCC); } [RemoteService(false)] public async Task InitializeEmailLog(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) { - if (string.IsNullOrEmpty(emailTo)) - { - return null; - } - var emailObject = await GetEmailObjectAsync(emailTo, body, subject, emailFrom, "html", emailTemplateName, emailCC, emailBCC); - EmailLog emailLog = new(); - emailLog = UpdateMappedEmailLog(emailLog, emailObject); - emailLog.ApplicationId = applicationId; - emailLog.Status = status ?? EmailStatus.Initialized; - - // When being called here the current tenant is in context - verified by looking at the tenant id - EmailLog loggedEmail = await emailLogsRepository.InsertAsync(emailLog, autoSave: true); - return loggedEmail; + return await emailNotificationManager.CreateEmailLogAsync(emailTo, body, subject, applicationId, emailFrom, status, emailTemplateName, emailCC, emailBCC); } protected virtual async Task NotifyTeamsChannel(string chesEmailError) @@ -146,7 +96,7 @@ public async Task SendCommentNotification(EmailCommentDto i
- SendEmailNotification: Exception occurred while sending email."); - return new HttpResponseMessage(HttpStatusCode.InternalServerError) - { - Content = new StringContent($"An exception occurred while sending the email: {ex.Message}") - }; - } + return await emailNotificationManager.SendEmailAsync(emailTo, body, subject, emailFrom, emailBodyType, emailTemplateName, emailCC, emailBCC); } /// @@ -231,64 +156,21 @@ public async Task SendEmailNotification(string emailTo, str /// /// The email log containing email details /// HttpResponseMessage indicating the result of the operation + [RemoteService(false)] public async Task SendEmailNotification(EmailLog emailLog) { - try - { - if (emailLog == null) - { - Logger.LogError("EmailNotificationService->SendEmailNotification: The 'emailLog' parameter is null."); - return new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent("'emailLog' cannot be null.") - }; - } - - if (string.IsNullOrEmpty(emailLog.ToAddress)) - { - Logger.LogError("EmailNotificationService->SendEmailNotification: The 'emailLog.ToAddress' parameter is null or empty."); - return new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent("'emailLog.ToAddress' cannot be null or empty.") - }; - - } - - // Build email object with attachments from S3 - var emailObject = await BuildEmailObjectWithAttachmentsAsync(emailLog); - - // Send via CHES - var response = await chesClientService.SendAsync(emailObject); - - return response; - } - catch (Exception ex) - { - Logger.LogError(ex, "EmailNotificationService->SendEmailNotification: Exception occurred while sending email for EmailLog {EmailId}.", emailLog?.Id); - return new HttpResponseMessage(HttpStatusCode.InternalServerError) - { - Content = new StringContent($"An exception occurred while sending the email: {ex.Message}") - }; - } + return await emailNotificationManager.SendEmailAsync(emailLog); } public async Task GetEmailLogById(Guid id) { - try - { - return await emailLogsRepository.GetAsync(id); - } - catch (EntityNotFoundException ex) - { - Logger.LogError(ex, "Entity not found for Email Log. Tenant context may be incorrect: {ExceptionMessage}", ex.Message); - return null; - } + return await emailNotificationManager.GetEmailLogByIdAsync(id); } [Authorize] public virtual async Task> GetHistoryByApplicationId(Guid applicationId) { - var entityList = await emailLogsRepository.GetByApplicationIdAsync(applicationId); + var entityList = await emailNotificationManager.GetEmailLogsByApplicationIdAsync(applicationId); var dtoList = ObjectMapper.Map, List>(entityList); var sentByUserIds = dtoList @@ -322,105 +204,10 @@ public virtual async Task> GetHistoryByApplicationId(Guid /// /// Send Email To Queue /// - /// The email log to send to q + /// The email log to send to queue public async Task SendEmailToQueue(EmailLog emailLog) { - EmailNotificationEvent emailNotificationEvent = new() - { - Id = emailLog.Id, - TenantId = emailLog.TenantId, - RetryAttempts = emailLog.RetryAttempts - }; - await emailQueueService.SendToEmailEventQueueAsync(emailNotificationEvent); - } - - protected virtual async Task GetEmailObjectAsync( - string emailTo, - string body, - string subject, - string? emailFrom, - string? emailBodyType, - string? emailTemplateName, - string? emailCC = null, - string? emailBCC = null) - { - var toList = emailTo.ParseEmailList() ?? []; - var ccList = emailCC.ParseEmailList(); - var bccList = emailBCC.ParseEmailList(); - - var defaultFromAddress = await SettingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); - - dynamic emailObject = new ExpandoObject(); - var emailObjectDictionary = (IDictionary)emailObject; - - emailObjectDictionary["body"] = body; - emailObjectDictionary["bodyType"] = emailBodyType ?? "text"; - emailObjectDictionary["cc"] = ccList; - emailObjectDictionary["bcc"] = bccList; - emailObjectDictionary["encoding"] = "utf-8"; - emailObjectDictionary["from"] = emailFrom ?? defaultFromAddress ?? "NoReply@gov.bc.ca"; - emailObjectDictionary["priority"] = "normal"; - emailObjectDictionary["subject"] = subject; - emailObjectDictionary["tag"] = "tag"; - emailObjectDictionary["to"] = toList; - emailObjectDictionary["templateName"] = emailTemplateName; - - return emailObject; - } - - public async Task BuildEmailObjectWithAttachmentsAsync(EmailLog emailLog) - { - // Get base email object (without attachments) - var emailObject = await GetEmailObjectAsync( - emailLog.ToAddress, - emailLog.Body, - emailLog.Subject, - emailLog.FromAddress, - emailLog.BodyType, - emailLog.TemplateName, - emailLog.CC, - emailLog.BCC); - - // Retrieve attachments from S3 - var attachments = await emailAttachmentService.GetAttachmentsAsync(emailLog.Id); - - if (attachments.Count != 0) - { - var attachmentList = new List(); - - foreach (var attachment in attachments) - { - byte[]? content = await emailAttachmentService.DownloadFromS3Async(attachment.S3ObjectKey); - if (content != null) - { - attachmentList.Add(new - { - content = Convert.ToBase64String(content), // Convert to Base64 for CHES - contentType = attachment.ContentType, - encoding = "base64", - filename = attachment.FileName - }); - } - } - - var emailObjectDictionary = (IDictionary)emailObject; - emailObjectDictionary["attachments"] = attachmentList.ToArray(); - } - - return emailObject; - } - - protected virtual EmailLog UpdateMappedEmailLog(EmailLog emailLog, dynamic emailDynamicObject) - { - emailLog.Body = emailDynamicObject.body; - emailLog.Subject = emailDynamicObject.subject; - emailLog.BodyType = emailDynamicObject.bodyType; - emailLog.FromAddress = emailDynamicObject.from; - emailLog.ToAddress = string.Join(",", emailDynamicObject.to); - emailLog.CC = emailDynamicObject.cc != null ? string.Join(",", (IEnumerable)emailDynamicObject.cc) : string.Empty; - emailLog.BCC = emailDynamicObject.bcc != null ? string.Join(",", (IEnumerable)emailDynamicObject.bcc) : string.Empty; - emailLog.TemplateName = emailDynamicObject.templateName; - return emailLog; + await emailNotificationManager.QueueEmailAsync(emailLog); } [Authorize(NotificationsPermissions.Settings)] diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs new file mode 100644 index 000000000..19885e38e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Unity.Notifications.Emails; + +namespace Unity.Notifications.EmailNotifications +{ + /// + /// Domain manager for email notification operations + /// + public interface IEmailNotificationManager + { + /// + /// Creates and initializes a new email log + /// + Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + + /// + /// Creates and initializes a new email log with status + /// + Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + + /// + /// Updates an existing email log + /// + Task UpdateEmailLogAsync(Guid emailId, string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + + /// + /// Retrieves an email log by ID + /// + Task GetEmailLogByIdAsync(Guid id); + + /// + /// Deletes an email log + /// + Task DeleteEmailLogAsync(Guid id); + + /// + /// Sends an email notification using CHES + /// + Task SendEmailAsync(string emailTo, string body, string subject, string? emailFrom, string? emailBodyType, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + + /// + /// Sends an email notification from an EmailLog (with S3 attachments support) + /// + Task SendEmailAsync(EmailLog emailLog); + + /// + /// Queues an email for batch processing + /// + Task QueueEmailAsync(EmailLog emailLog); + + /// + /// Gets the count of pending emails (sent without response or initialized > 10 minutes) + /// + Task GetPendingEmailsCountAsync(); + + /// + /// Builds an email object with attachments from S3 + /// + Task BuildEmailObjectWithAttachmentsAsync(EmailLog emailLog); + + /// + /// Gets all email logs for a specific application + /// + Task> GetEmailLogsByApplicationIdAsync(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs index 396c0fcae..adf4b2dac 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs @@ -24,6 +24,7 @@ public class EmailNotificationEvent public EmailAction Action { get; set; } public string? EmailTemplateName { get; set; } = string.Empty; public List? EmailAttachments { get; set; } + public List? PaymentRequestIds { get; set; } } public class EmailAttachmentData diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs index b0002e3df..b1f17f60e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs @@ -18,7 +18,8 @@ namespace Unity.GrantManager.Events internal class EmailNotificationHandler( IEmailNotificationService emailNotificationService, IFeatureChecker featureChecker, - EmailAttachmentService emailAttachmentService, + EmailAttachmentService emailAttachmentService, + IEmailLogsRepository emailLogsRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, ILogger logger) : ILocalEventHandler, ITransientDependency @@ -135,7 +136,7 @@ private async Task InitializeEmail(string emailTo, string body, string case EmailAction.SendFsbNotification: { string fsbEmailToAddress = String.Join(",", eventData.EmailAddressList); - return await InitializeEmailAndUploadAttachments( + var emailLog = await InitializeEmailAndUploadAttachments( fsbEmailToAddress, eventData.Body, eventData.Subject ?? "FSB Payment Notification", @@ -145,6 +146,15 @@ private async Task InitializeEmail(string emailTo, string body, string null, // emailCC null, // emailBCC eventData.EmailAttachments); + + // Store payment request IDs for tracking + if (eventData.PaymentRequestIds != null && eventData.PaymentRequestIds.Count != 0) + { + emailLog.PaymentRequestIds = string.Join(",", eventData.PaymentRequestIds); + await emailLogsRepository.UpdateAsync(emailLog, autoSave: true); + } + + return emailLog; } case EmailAction.Retry: default: diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs index d9ba49db7..6276a5608 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs @@ -16,6 +16,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; using Unity.Notifications.Emails; +using Volo.Abp.EventBus.Local; namespace Unity.Notifications.Integrations.RabbitMQ; @@ -25,6 +26,7 @@ public class EmailConsumer( EmailQueueService emailQueueService, IUnitOfWorkManager unitOfWorkManager, ICurrentTenant currentTenant, + ILocalEventBus localEventBus, ILogger logger ) : IQueueConsumer { @@ -118,7 +120,7 @@ private async Task ProcessEmailAsync( return; } - UpdateEmailLogStatus(emailLog, response); + await UpdateEmailLogStatus(emailLog, response); if (ShouldRetry(response.StatusCode)) { @@ -174,7 +176,7 @@ private static bool ShouldRetry(HttpStatusCode statusCode) => // ----------------------------- // STATUS UPDATE // ----------------------------- - private static void UpdateEmailLogStatus(EmailLog log, HttpResponseMessage response) + private async Task UpdateEmailLogStatus(EmailLog log, HttpResponseMessage response) { log.ChesResponse = JsonConvert.SerializeObject(new { @@ -183,11 +185,30 @@ private static void UpdateEmailLogStatus(EmailLog log, HttpResponseMessage respo Body = response.Content != null ? response.Content.ReadAsStringAsync().Result : null }); + log.ChesHttpStatusCode = response.StatusCode.ToString("D"); + log.ChesStatus = response.StatusCode.ToString(); log.Status = response.IsSuccessStatusCode ? EmailStatus.Sent : EmailStatus.Failed; + + // Publish local event for successful FSB payment notifications + if (response.IsSuccessStatusCode && !string.IsNullOrEmpty(log.PaymentRequestIds)) + { + var paymentIds = log.PaymentRequestIds + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(id => Guid.Parse(id)) + .ToList(); + + await localEventBus.PublishAsync(new FsbEmailSentEto + { + EmailLogId = log.Id, + PaymentRequestIds = paymentIds, + SentDate = DateTime.UtcNow, + TenantId = log.TenantId + }); + } } // ----------------------------- diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Events/FsbEmailSentEto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Events/FsbEmailSentEto.cs new file mode 100644 index 000000000..e8effec37 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Events/FsbEmailSentEto.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace Unity.Notifications.Events +{ + /// + /// Local event published when FSB payment notification email is successfully sent via CHES + /// + public class FsbEmailSentEto + { + public Guid EmailLogId { get; set; } + public List PaymentRequestIds { get; set; } = new(); + public DateTime SentDate { get; set; } + public Guid? TenantId { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailLog.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailLog.cs index 9d304685a..98e3dce0d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailLog.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailLog.cs @@ -27,8 +27,10 @@ public class EmailLog : AuditedAggregateRoot, IMultiTenant public Guid? ChesMsgId { get; set; } public string ChesResponse { get; set; } = string.Empty; public string ChesStatus { get; set; } = string.Empty; + public string? ChesHttpStatusCode { get; set; } public string Status { get; set; } = string.Empty; public DateTime? SendOnDateTime { get; set; } public DateTime? SentDateTime { get; set; } public string TemplateName { get; set; } = string.Empty; + public string PaymentRequestIds { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs index 71e042be8..1c4d275e6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs @@ -73,9 +73,9 @@ await templateVariablesRepository.InsertAsync( var emailGroups = new List { - new EmailGroupDto {Name = "FSB-AP", Description = "This group manages the recipients for PO-related payments, which will be sent to FSB-AP to update contracts and initiate payment creation.",Type = "static"}, - new EmailGroupDto {Name = "Payments", Description = "This group manages the recipients for payment notifications, such as failures or errors",Type = "dynamic"} - }; + new EmailGroupDto {Name = "FSB-AP", Description = "This group manages the recipients for PO-related payments, which will be sent to FSB-AP to update contracts and initiate payment creation.",Type = "static"}, + new EmailGroupDto {Name = "Payments", Description = "This group manages the recipients for payment notifications, such as failures or errors",Type = "static"} + }; try { var allGroups = await emailGroupsRepository.GetListAsync(); @@ -110,4 +110,4 @@ internal class EmailTempateVariableDto public string Token { get; set; } = string.Empty; public string MapTo { get; set; } = string.Empty; } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml index 28ab07b71..081a16a1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml @@ -1,5 +1,4 @@ -@using Unity.Notifications.Web.Bundling -@model Unity.Notifications.Web.Views.Settings.NotificationsSettingGroup.NotificationsSettingViewModel +@model Unity.Notifications.Web.Views.Settings.NotificationsSettingGroup.NotificationsSettingViewModel @@ -9,60 +8,78 @@

Notifications

+ + + + + + + - - - \ No newline at end of file + diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js index 559096873..fbb5a29cb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js @@ -1,94 +1,244 @@ -(function ($) { - $(function () { - loadCardsFromService(); - const NotificationUiElements = { - settingForm: $("#NotificationsSettingsForm"), - saveButton: $("#NotificationsSaveButton"), - discardButton: $("#NotificationsDiscardButton") - } - - let initialFormState = NotificationUiElements.settingForm.serialize(); - - function checkFormChanges() { - let currentFormState = NotificationUiElements.settingForm.serialize(); - let isFormChanged = currentFormState !== initialFormState; +$(function () { + loadCardsFromService(); + const NotificationUiElements = { + settingForm: $("#NotificationsSettingsForm"), + saveButton: $("#NotificationsSaveButton"), + discardButton: $("#NotificationsDiscardButton") + } - NotificationUiElements.saveButton.prop('disabled', !isFormChanged); - NotificationUiElements.discardButton.prop('disabled', !isFormChanged); - } + let initialFormState = NotificationUiElements.settingForm.serialize(); - NotificationUiElements.settingForm.on('input change', function () { - checkFormChanges(); - }); + function checkFormChanges() { + let currentFormState = NotificationUiElements.settingForm.serialize(); + let isFormChanged = currentFormState !== initialFormState; - NotificationUiElements.settingForm.on('submit', function (event) { - event.preventDefault(); + NotificationUiElements.saveButton.prop('disabled', !isFormChanged); + NotificationUiElements.discardButton.prop('disabled', !isFormChanged); + } - if (!$(this).valid()) { - return; - } + NotificationUiElements.settingForm.on('input change', function () { + checkFormChanges(); + }); - let form = $(this).serializeFormToObject(); - unity.notifications.emailNotifications.emailNotification.updateSettings(form).then(function (result) { - $(document).trigger("AbpSettingSaved"); - initialFormState = NotificationUiElements.settingForm.serialize(); - checkFormChanges(); - }); + NotificationUiElements.settingForm.on('submit', function (event) { + event.preventDefault(); - }); + if (!$(this).valid()) { + return; + } - NotificationUiElements.discardButton.on('click', function () { - NotificationUiElements.settingForm[0].reset(); + let form = $(this).serializeFormToObject(); + unity.notifications.emailNotifications.emailNotification.updateSettings(form).then(function (result) { + $(document).trigger("AbpSettingSaved"); initialFormState = NotificationUiElements.settingForm.serialize(); checkFormChanges(); }); + }); + + NotificationUiElements.discardButton.on('click', function () { + NotificationUiElements.settingForm[0].reset(); + initialFormState = NotificationUiElements.settingForm.serialize(); checkFormChanges(); + }); - let editorInstances = {}; - - - function createCard(data = null) { - const isPopulated = data !== null; - const id = data?.id?.toString() || generateTempId(); - const cardId = `collapseDetails-${id}`; - const formId = `form-${id}`; - const wrapperId = `cardWrapper-${id}`; - const editorId = `editor-${id}`; - const type = data?.type || 'Automatic'; - let lastEdited; - const dropdownItems = []; - getTemplateVariables(); - if (data?.lastModificationTime) { - lastEdited = new Date(data.lastModificationTime).toLocaleDateString('en-CA'); - } else if (data?.creationTime) { - lastEdited = new Date(data.creationTime).toLocaleDateString('en-CA'); - } else { - lastEdited = new Date().toLocaleDateString('en-CA'); + checkFormChanges(); + + let editorInstances = {}; + + // Utility function for debouncing + function debounce(func, delay) { + let timeoutId; + return function (...args) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(this, args), delay); + }; + } + + // Utility functions for template operations + function extractFormData(formDataArray) { + return { + name: formDataArray[0].value, + sendFrom: formDataArray[1].value, + subject: formDataArray[2].value + }; + } + + function buildTemplatePayload(data, editor) { + return JSON.stringify({ + name: data.name, + description: "", + subject: data.subject, + bodyText: editor.getContent({ format: 'text' }), + bodyHTML: editor.getContent(), + sendFrom: data.sendFrom + }); + } + + function handleSuccess(message) { + abp.notify.success(message); + $("#cardContainer").empty(); + loadCardsFromService(); + return true; + } + + function handleError(message) { + abp.notify.error(message); + return false; + } + + function onSaveTemplateSuccess() { + handleSuccess('Template saved successfully.'); + } + + function onSaveTemplateError() { + handleError('Failed to save template.'); + } + + function saveTemplate(payload) { + $.ajax({ + url: `/api/app/template`, + method: 'POST', + contentType: 'application/json', + data: payload, + success: onSaveTemplateSuccess, + error: onSaveTemplateError + }); + } + + function updateTemplate(id, payload) { + $.ajax({ + url: `/api/app/template/${id}/template`, + method: 'PUT', + contentType: 'application/json', + data: payload, + success: onSaveTemplateSuccess, + error: onSaveTemplateError + }); + } + + function checkTemplateNameUnique(name, currentId, callback) { + $.ajax({ + url: `/api/app/template/template-by-name?name=${encodeURIComponent(name)}`, + type: 'GET', + success: function (response) { + const wrapperId = `cardWrapper-${currentId}`; + const isSameAsCurrent = !currentId.includes('temp') && name === $(`#${wrapperId}`).data('original-name'); + let isExist = false; + if (response?.id) { + isExist = true; + } + callback(!isExist || isSameAsCurrent); + }, + error: function () { + callback(false); } - const disabled = isPopulated ? 'disabled' : ''; + }); + } + + function getTemplateVariables(dropdownItems) { + $.ajax({ + url: `/api/app/template/template-variables`, + type: 'GET', + success: function (response) { + $.map(response, function (item) { + dropdownItems.push({ + text: item.name, + value: item.token + }); + }); + }, + error: function () { + // Handle error silently + } + }); + } + + function showDeleteConfirmation(id, wrapperId) { + const swalOptions = { + title: "Delete Template", + text: "Are you sure you want to delete this template?", + showCancelButton: true, + confirmButtonText: "Confirm", + customClass: { + confirmButton: 'btn btn-primary', + cancelButton: 'btn btn-secondary' + } + }; - const cardHtml = ` -
+ Swal.fire(swalOptions).then(function(result) { + handleDeleteConfirmation(result, id, wrapperId); + }); + } + + function handleDeleteConfirmation(result, id, wrapperId) { + if (!result.isConfirmed) return; + deleteTemplate(id, wrapperId); + } + + function handleDeleteSuccess(wrapperId) { + return function() { + $(`#${wrapperId}`).remove(); + abp.notify.success('Template deleted successfully.'); + }; + } + + function handleDeleteError() { + abp.notify.error('Error deleting the template.'); + } + + function deleteTemplate(id, wrapperId) { + $.ajax({ + url: `/api/app/template/${id}/template`, + type: 'DELETE', + success: handleDeleteSuccess(wrapperId), + error: handleDeleteError + }); + } + + // Helper functions moved outside of createCard to reduce complexity + function getCardLastEditedDate(data) { + if (data?.lastModificationTime) { + return new Date(data.lastModificationTime).toLocaleDateString('en-CA'); + } else if (data?.creationTime) { + return new Date(data.creationTime).toLocaleDateString('en-CA'); + } else { + return new Date().toLocaleDateString('en-CA'); + } + } + + function generateCardHtml(cardConfig) { + const { + data, + elementIds, + displayInfo, + isPopulated + } = cardConfig; + + const disabled = isPopulated ? 'disabled' : ''; + const cardDataId = data?.id?.toString() || elementIds.wrapperId; + + return ` +
${data?.name || 'Untitled Template'}
-
Type
${type}
+
Type
${displayInfo.type}
-
Last Edited
${lastEdited}
+
Last Edited
${displayInfo.lastEdited}
-
+
-
+
@@ -103,356 +253,263 @@
- - - +
-

NOTE: Selecting text will let your customize it: replace it with a variable, make it bold, italic, change the alignment, add a link, create a list, etc.

+

NOTE: Selecting text will let you customize it: replace it with a variable, make it bold, italic, change the alignment, add a link, create a list, etc.



-
${isPopulated ? `` : ``} ${isPopulated ? `` : ''} -
`; + } - $("#cardContainer").append(cardHtml); - $(`#${wrapperId}`).data('original-name', data?.name || ''); - // editorInstances[id] = - console.log("tinymce", tinymce) - if (tinymce.get(editorId)) { - tinymce.get(editorId).remove(); // remove existing instance - } - - function getToolbarOptions() { - return 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview | variablesDropdownButton'; - } - - function getPlugins() { - return 'lists link image preview code'; - } - - - function setupEditor(editor, id, editorId, data, isPopulated) { - editor.ui.registry.addMenuButton('variablesDropdownButton', { - text: 'VARIABLES', - fetch: function (callback) { - const items = dropdownItems.map(item => ({ - type: 'menuitem', - text: item.text, - onAction: () => { - editor.insertContent(`{{${item.value}}}`); - } - })); - callback(items); - } - }); - - editor.on('init', function () { - editor.mode.set(isPopulated ? 'readonly' : 'design'); - if (data?.bodyHTML) { - editor.setContent(data.bodyHTML); - } - editorInstances[id] = editor; - console.log(`Editor initialized: ${editorId}`); - }); - } - - function initTinyMCE(editorId, id, data, isPopulated) { - tinymce.init({ - license_key: 'gpl', - selector: `#${editorId}`, - plugins: getPlugins(), - toolbar: getToolbarOptions(), - statusbar: false, - promotion: false, - content_css: false, - skin: false, - setup: function (editor) { - console.log("editor", editor); - setupEditor(editor, id, editorId, data, isPopulated); - } - }); - } - - - initTinyMCE(editorId, id, data, isPopulated) - - - - function extractFormData(formDataArray) { - return { - name: formDataArray[0].value, - sendFrom: formDataArray[1].value, - subject: formDataArray[2].value - }; - } - - function buildTemplatePayload(data, editor) { - return JSON.stringify({ - name: data.name, - description: "", - subject: data.subject, - bodyText: editor.getContent({ format: 'text' }), - bodyHTML: editor.getContent(), - sendFrom: data.sendFrom - }); - } - - function handleSuccess(message) { - abp.notify.success(message); - $("#cardContainer").empty(); - loadCardsFromService(); - return true; - } - - function handleError(message) { - abp.notify.error(message); - return false; - } - - function onSaveTemplateSuccess() { - handleSuccess('Template saved successfully.'); - } + function initializeEditor(editorId, id, data, isPopulated, dropdownItems) { + if (tinymce.get(editorId)) { + tinymce.get(editorId).remove(); + } - function onSaveTemplateError() { - handleError('Failed to save template.'); + tinymce.init({ + license_key: 'gpl', + selector: `#${editorId}`, + plugins: 'lists link image preview code', + toolbar: 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview | variablesDropdownButton', + statusbar: false, + promotion: false, + content_css: false, + skin: false, + setup: function (editor) { + setupEditor(editor, id, editorId, data, isPopulated, dropdownItems); } - - function saveTemplate(payload) { - $.ajax({ - url: `/api/app/template`, - method: 'POST', - contentType: 'application/json', - data: payload, - success: onSaveTemplateSuccess, - error: onSaveTemplateError - }); + }); + } + + function createMenuItems(dropdownItems, editor) { + return dropdownItems.map(item => ({ + type: 'menuitem', + text: item.text, + onAction: () => { + editor.insertContent(`{{${item.value}}}`); } + })); + } + + function fetchVariablesMenuItems(dropdownItems, editor) { + return function (callback) { + const items = createMenuItems(dropdownItems, editor); + callback(items); + }; + } + + function setupEditor(editor, id, editorId, data, isPopulated, dropdownItems) { + editor.ui.registry.addMenuButton('variablesDropdownButton', { + text: 'VARIABLES', + fetch: fetchVariablesMenuItems(dropdownItems, editor) + }); - function updateTemplate(id, payload) { - $.ajax({ - url: `/api/app/template/${id}/template`, - method: 'PUT', - contentType: 'application/json', - data: payload, - success: onSaveTemplateSuccess, - error: onSaveTemplateError - - }); + editor.on('init', function () { + editor.mode.set(isPopulated ? 'readonly' : 'design'); + if (data?.bodyHTML) { + editor.setContent(data.bodyHTML); } + editorInstances[id] = editor; + }); + } - $(`#${formId} input[name="templateName"]`).on('input', function () { - const templateInput = $(this); - const newTitle = templateInput.val().trim() || 'Untitled Template'; - $(`#${wrapperId} .template-title`).text(newTitle); - - // Check if name is unique - checkTemplateNameUnique(newTitle, id, function (isUnique) { - if (!isUnique) { - templateInput.addClass("is-invalid"); - if (!$(`#${formId} .template-name-feedback`).length) { - templateInput.after(`
Template name must be unique.
`); - } - $(`#${formId} .saveBtn`).prop("disabled", true); - } else { - templateInput.removeClass("is-invalid"); - $(`#${formId} .template-name-feedback`).remove(); - $(`#${formId} .saveBtn`).prop("disabled", false); - } - }); - }); - - $(`#${formId}`).on("submit", function (e) { - e.preventDefault(); - - const formDataArray = $(this).serializeArray(); - const formData = extractFormData(formDataArray); - const editor = editorInstances[id]; - const payload = buildTemplatePayload(formData, editor); - - if (id.includes("temp")) { - saveTemplate(payload); - } else { - updateTemplate(id, payload); - } - }); - - $(`#${cardId}`).on('show.bs.collapse', function () { - $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`) - .removeClass('fa-chevron-down') - .addClass('fa-chevron-up'); - }); - - $(`#${cardId}`).on('hide.bs.collapse', function () { - $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`) - .removeClass('fa-chevron-up') - .addClass('fa-chevron-down'); - }); - - $(`#${wrapperId}`).on("click", ".editBtn", function () { - const currentEditor = editorInstances[id]; - currentEditor.destroy(); - - initTinyMCE(editorId, id, data, false) - - const card = $(`#${wrapperId}`); - card.find(".form-input").prop('disabled', false); - card.find(".saveBtn").prop('disabled', false); - card.find(".discardBtn").removeClass("d-none"); - $(this).addClass("d-none"); - }); - - $(`#${wrapperId}`).on("click", ".discardBtn", function () { - const form = $(`#${formId}`)[0]; - form.reset(); - const currentEditor = editorInstances[id]; - currentEditor.destroy(); - - initTinyMCE(editorId, id, data, true) - - $(`#${wrapperId} .form-input`).prop('disabled', true); - $(`#${wrapperId} .saveBtn`).prop('disabled', true); - $(`#${wrapperId} .discardBtn`).addClass("d-none"); - $(`#${wrapperId} .editBtn`).removeClass("d-none"); + function setupCardEventHandlers(cardData) { + const { id, formId, cardId, wrapperId, editorId, data, isPopulated, dropdownItems } = cardData; + + setupTemplateNameValidation(formId, wrapperId, id); + setupFormSubmission(formId, id); + setupCollapseHandlers(cardId, wrapperId); + setupEditDiscardHandlers(wrapperId, formId, editorId, id, data, dropdownItems); + setupDeleteHandler(wrapperId, id, isPopulated); + } + + function setupTemplateNameValidation(formId, wrapperId, id) { + const debouncedValidation = debounce(function (templateInput, newTitle) { + checkTemplateNameUnique(newTitle, id, function (isUnique) { + toggleTemplateNameValidation(templateInput, formId, isUnique); }); + }, 250); - $(`#${formId} input[name="templateName"]`).on('input', function () { - const newTitle = $(this).val().trim() || 'Untitled Template'; - $(`#${wrapperId} .template-title`).text(newTitle); - }); + $(`#${formId} input[name="templateName"]`).on('input', function () { + const templateInput = $(this); + const newTitle = templateInput.val().trim() || 'Untitled Template'; + $(`#${wrapperId} .template-title`).text(newTitle); - $(`#${wrapperId}`).on("click", ".deleteCardBtn", function () { - if (isPopulated) { - showDeleteConfirmation(id, wrapperId); - } else { - $(`#${wrapperId}`).remove(); - } - }); + debouncedValidation(templateInput, newTitle); + }); + } - function showDeleteConfirmation(id, wrapperId) { - const swalOptions = { - title: "Delete Template", - text: "Are you sure you want to delete this template?", - showCancelButton: true, - confirmButtonText: "Confirm", - customClass: { - confirmButton: 'btn btn-primary', - cancelButton: 'btn btn-secondary' - } - }; - - Swal.fire(swalOptions).then(handleResult.bind(null, id, wrapperId)); - } - function handleResult(id, wrapperId, result) { - handleDeleteConfirmation(result, id, wrapperId); + function toggleTemplateNameValidation(templateInput, formId, isUnique) { + if (!isUnique) { + templateInput.addClass("is-invalid"); + if (!$(`#${formId} .template-name-feedback`).length) { + templateInput.after(`
Template name must be unique.
`); } - - function handleDeleteConfirmation(result, id, wrapperId) { - if (!result.isConfirmed) return; - deleteTemplate(id, wrapperId); + $(`#${formId} .saveBtn`).prop("disabled", true); + } else { + templateInput.removeClass("is-invalid"); + $(`#${formId} .template-name-feedback`).remove(); + $(`#${formId} .saveBtn`).prop("disabled", false); + } + } + + function setupFormSubmission(formId, id) { + $(`#${formId}`).on("submit", function (e) { + e.preventDefault(); + + const formDataArray = $(this).serializeArray(); + const formData = extractFormData(formDataArray); + const editor = editorInstances[id]; + const payload = buildTemplatePayload(formData, editor); + + if (id.includes("temp")) { + saveTemplate(payload); + } else { + updateTemplate(id, payload); } - function handleDeleteSuccess() { - $(`#${wrapperId}`).remove(); - abp.notify.success('Template deleted successfully.'); + }); + } - } + function setupCollapseHandlers(cardId, wrapperId) { + $(`#${cardId}`).on('show.bs.collapse', function () { + $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`) + .removeClass('fa-chevron-down') + .addClass('fa-chevron-up'); + }); - function handleDeleteError() { - abp.notify.error('Error deleting the template.'); - } + $(`#${cardId}`).on('hide.bs.collapse', function () { + $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`) + .removeClass('fa-chevron-up') + .addClass('fa-chevron-down'); + }); + } - function deleteTemplate(id, wrapperId) { - $.ajax({ - url: `/api/app/template/${id}/template`, - type: 'DELETE', - success: handleDeleteSuccess, - error: handleDeleteError - }); - } - function checkTemplateNameUnique(name, currentId, callback) { - $.ajax({ - url: `/api/app/template/template-by-name?name=${encodeURIComponent(name)}`, - type: 'GET', - success: function (response) { - // Assume response: { isUnique: true/false } - // If editing an existing template, allow current name - const isSameAsCurrent = !currentId.includes('temp') && name === $(`#${wrapperId}`).data('original-name'); - let isExist = false; - if (response?.id) { - isExist = true; - } - - callback(!isExist || isSameAsCurrent); - }, - error: function () { - callback(false); // Assume not unique if error - } - }); - } + function setupEditDiscardHandlers(wrapperId, formId, editorId, id, data, dropdownItems) { + $(`#${wrapperId}`).on("click", ".editBtn", function () { + handleEditClick(wrapperId, editorId, id, data, dropdownItems); + }); - function getTemplateVariables() { - $.ajax({ - url: `/api/app/template/template-variables`, - type: 'GET', - success: function (response) { - $.map(response, function (item) { - dropdownItems.push( { - text: item.name, - value: item.token - }); - }); - }, - error: function () { - - } - }); + $(`#${wrapperId}`).on("click", ".discardBtn", function () { + handleDiscardClick(wrapperId, formId, editorId, id, data, dropdownItems); + }); + } + + function handleEditClick(wrapperId, editorId, id, data, dropdownItems) { + const currentEditor = editorInstances[id]; + currentEditor.destroy(); + + initializeEditor(editorId, id, data, false, dropdownItems); + + const card = $(`#${wrapperId}`); + card.find(".form-input").prop('disabled', false); + card.find(".saveBtn").prop('disabled', false); + card.find(".discardBtn").removeClass("d-none"); + card.find(".editBtn").addClass("d-none"); + } + + function handleDiscardClick(wrapperId, formId, editorId, id, data, dropdownItems) { + const form = $(`#${formId}`)[0]; + form.reset(); + const currentEditor = editorInstances[id]; + currentEditor.destroy(); + + initializeEditor(editorId, id, data, true, dropdownItems); + + $(`#${wrapperId} .form-input`).prop('disabled', true); + $(`#${wrapperId} .saveBtn`).prop('disabled', true); + $(`#${wrapperId} .discardBtn`).addClass("d-none"); + $(`#${wrapperId} .editBtn`).removeClass("d-none"); + } + + function setupDeleteHandler(wrapperId, id, isPopulated) { + $(`#${wrapperId}`).on("click", ".deleteCardBtn", function () { + if (isPopulated) { + showDeleteConfirmation(id, wrapperId); + } else { + $(`#${wrapperId}`).remove(); } - - - } - - function loadCardsFromService() { - $.ajax({ - url: `/api/app/template/templates-by-tenent`, - type: 'GET', - success: handleLoadCardsSuccess, - error: handleLoadCardsError - }); - } - - function handleLoadCardsSuccess(response) { - editorInstances = {}; - response.forEach(item => createCard(item)); - } - - function handleLoadCardsError() { - abp.notify.error('Unable to load the templates.'); - } - function generateTempId() { - const array = new Uint32Array(1); - window.crypto.getRandomValues(array); - return `temp-${array[0].toString(36)}`; - } - - $("#CreateNewTemplate").on("click", function () { - createCard(); }); - - - + } + + function createCard(data = null) { + const isPopulated = data !== null; + const id = data?.id?.toString() || generateTempId(); + const cardId = `collapseDetails-${id}`; + const formId = `form-${id}`; + const wrapperId = `cardWrapper-${id}`; + const editorId = `editor-${id}`; + const type = data?.type || 'Automatic'; + const lastEdited = getCardLastEditedDate(data); + const dropdownItems = []; + getTemplateVariables(dropdownItems); + + const cardConfig = { + data: data, + elementIds: { + cardId: cardId, + formId: formId, + wrapperId: wrapperId, + editorId: editorId + }, + displayInfo: { + type: type, + lastEdited: lastEdited + }, + isPopulated: isPopulated + }; + + const cardHtml = generateCardHtml(cardConfig); + + $("#cardContainer").append(cardHtml); + $(`#${wrapperId}`).data('original-name', data?.name || ''); + + initializeEditor(editorId, id, data, isPopulated, dropdownItems); + + const cardData = { + id, formId, cardId, wrapperId, editorId, data, isPopulated, dropdownItems + }; + setupCardEventHandlers(cardData); + } + + function loadCardsFromService() { + $.ajax({ + url: `/api/app/template/templates-by-tenent`, + type: 'GET', + success: handleLoadCardsSuccess, + error: handleLoadCardsError + }); + } + + function handleLoadCardsSuccess(response) { + editorInstances = {}; + response.forEach(item => createCard(item)); + } + + function handleLoadCardsError() { + abp.notify.error('Unable to load the templates.'); + } + function generateTempId() { + const array = new Uint32Array(1); + window.crypto.getRandomValues(array); + return `temp-${array[0].toString(36)}`; + } + + $("#CreateNewTemplate").on("click", function () { + createCard(); }); -})(jQuery); \ No newline at end of file +}); \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs index ae976271e..37a9622fc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs @@ -1,5 +1,4 @@ namespace Unity.Notifications.Web.Views.Settings; - using System.Threading.Tasks; using Unity.Notifications.Permissions; using Unity.Notifications.Web.Views.Settings.NotificationsSettingGroup; @@ -8,15 +7,12 @@ public class NotificationsSettingPageContributor : SettingPageContributorBase { - public NotificationsSettingPageContributor() + public override Task ConfigureAsync(SettingPageCreationContext context) { RequiredFeatures(SettingManagementFeatures.Enable); RequiredTenantSideFeatures("Unity.Notifications"); RequiredPermissions(NotificationsPermissions.Settings); - } - public override Task ConfigureAsync(SettingPageCreationContext context) - { context.Groups.Add( new SettingPageGroup( "GrantManager.Notifications", diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs index c3e72c776..be35d094c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs @@ -20,5 +20,6 @@ public interface IPaymentRequestAppService : IApplicationService Task GetDefaultAccountCodingId(); Task GetUserPaymentThresholdAsync(); Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds); + Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs index e782f3f36..e00b7be5a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs @@ -41,6 +41,11 @@ public class PaymentRequestDto : AuditedEntityDto public Collection PaymentTags { get; set; } public Collection ExpenseApprovals { get; set; } + // FSB Notification Tracking + public Guid? FsbNotificationEmailLogId { get; set; } + public DateTime? FsbNotificationSentDate { get; set; } + public string? FsbApNotified { get; set; } + public static explicit operator PaymentRequestDto(CreatePaymentRequestDto v) { throw new NotImplementedException(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs index 7354be499..e4291ba6c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs @@ -19,12 +19,12 @@ public class SupplierDto : ExtensibleFullAuditedEntityDto public DateTime? LastUpdatedInCAS { get; set; } /* Address */ - public string? MailingAddress { get; private set; } - public string? City { get; private set; } - public string? Province { get; private set; } - public string? PostalCode { get; private set; } + public string? MailingAddress { get; set; } + public string? City { get; set; } + public string? Province { get; set; } + public string? PostalCode { get; set; } - public Collection Sites { get; private set; } + public Collection Sites { get; set; } } #pragma warning restore CS8618 } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs index 4ea6b8752..91edb85a6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs @@ -63,6 +63,12 @@ public virtual Site Site public virtual Guid? AccountCodingId { get; private set; } public virtual AccountCoding? AccountCoding { get; set; } = null; public virtual string? Note { get; private set; } = null; + + // FSB Notification Tracking + public virtual Guid? FsbNotificationEmailLogId { get; private set; } + public virtual DateTime? FsbNotificationSentDate { get; private set; } + public virtual string? FsbApNotified { get; private set; } + protected PaymentRequest() { ExpenseApprovals = []; @@ -176,6 +182,22 @@ public PaymentRequest SetCasResponse(string casResponse) return this; } + public PaymentRequest SetFsbNotificationEmailLog(Guid emailLogId, DateTime sentDate) + { + FsbNotificationEmailLogId = emailLogId; + FsbNotificationSentDate = sentDate; + FsbApNotified = "Yes"; + return this; + } + + public PaymentRequest ClearFsbNotificationEmailLog() + { + FsbNotificationEmailLogId = null; + FsbNotificationSentDate = null; + FsbApNotified = null; + return this; + } + public PaymentRequest ValidatePaymentRequest() { if (Amount <= 0) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IInvoiceManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IInvoiceManager.cs new file mode 100644 index 000000000..71760dec7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IInvoiceManager.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Domain.AccountCodings; +using Unity.Payments.Integrations.Http; + +namespace Unity.Payments.Domain.Services +{ + public interface IInvoiceManager + { + Task GetSiteByPaymentRequestAsync(PaymentRequest paymentRequest); + Task GetPaymentRequestDataAsync(string invoiceNumber); + Task UpdatePaymentRequestWithInvoiceAsync(Guid paymentRequestId, InvoiceResponse invoiceResponse); + + } + + public class PaymentRequestData + { + public PaymentRequest PaymentRequest { get; set; } = null!; + public AccountCoding AccountCoding { get; set; } = null!; + public string AccountDistributionCode { get; set; } = null!; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestConfigurationManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestConfigurationManager.cs new file mode 100644 index 000000000..8c39cb057 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestConfigurationManager.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading.Tasks; +using Unity.Payments.Domain.PaymentConfigurations; + +namespace Unity.Payments.Domain.Services +{ + public interface IPaymentRequestConfigurationManager + { + // Configuration & Lookup + Task GetDefaultAccountCodingIdAsync(); + Task GetPaymentConfigurationAsync(); + Task GetNextBatchInfoAsync(); + Task GetMaxBatchNumberAsync(); + + // Threshold & Approval Logic + Task GetPaymentRequestThresholdByApplicationIdAsync(Guid applicationId, decimal? userPaymentThreshold); + Task GetUserPaymentThresholdAsync(Guid? userId); + + // Utility Methods for Batch/Sequence Generation + Task GetNextSequenceNumberAsync(int currentYear); + string GenerateReferenceNumberPrefix(string paymentIdPrefix); + string GenerateSequenceNumber(int sequenceNumber, int index); + string GenerateReferenceNumber(string referenceNumber, string sequencePart); + string GenerateInvoiceNumber(string referenceNumber, string invoiceNumber, string sequencePart); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs new file mode 100644 index 000000000..222dc9f83 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.PaymentRequests; + +namespace Unity.Payments.Domain.Services +{ + public interface IPaymentRequestQueryManager + { + // Payment Request Queries + Task GetPaymentRequestCountBySiteIdAsync(Guid siteId); + Task GetPaymentRequestCountAsync(); + Task GetPaymentRequestByIdAsync(Guid paymentRequestId); + Task> GetPaymentRequestsByIdsAsync(List paymentRequestIds, bool includeDetails = false); + Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting); + Task> GetListByApplicationIdAsync(Guid applicationId); + Task> GetListByApplicationIdsAsync(List applicationIds); + Task> GetListByPaymentIdsAsync(List paymentIds); + Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId); + + // Payment Request Operations + Task InsertPaymentRequestAsync(PaymentRequest paymentRequest); + + // DTO Creation & Mapping + Task CreatePaymentRequestDtoAsync(Guid paymentRequestId); + Task> MapToDtoAndLoadDetailsAsync(List paymentsList); + Task GetAccountDistributionCodeAsync(AccountCodingDto? accountCoding); + + // Queue Operations + Task ManuallyAddPaymentRequestsToReconciliationQueueAsync(List paymentRequestIds); + + // Helper Method + void ApplyErrorSummary(List mappedPayments); + + // Pending Payments + Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs new file mode 100644 index 000000000..565b400a5 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs @@ -0,0 +1,154 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Unity.Payments.Codes; +using Unity.Payments.Domain.AccountCodings; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Integrations.Http; +using Unity.Payments.PaymentConfigurations; +using Volo.Abp; +using Volo.Abp.Data; +using Volo.Abp.Domain.Services; +using Volo.Abp.Uow; + +namespace Unity.Payments.Domain.Services +{ + public class InvoiceManager( + IAccountCodingRepository accountCodingRepository, + PaymentConfigurationAppService paymentConfigurationAppService, + IPaymentRequestRepository paymentRequestRepository, + ISupplierRepository supplierRepository, + ISiteRepository siteRepository, + IUnitOfWorkManager unitOfWorkManager) : DomainService, IInvoiceManager + { + public async Task GetSiteByPaymentRequestAsync(PaymentRequest paymentRequest) + { + Site? site = await siteRepository.GetAsync(paymentRequest.SiteId, true); + if (site?.SupplierId != null) + { + Supplier supplier = await supplierRepository.GetAsync(site.SupplierId); + site.Supplier = supplier; + } + return site; + } + + public async Task GetPaymentRequestDataAsync(string invoiceNumber) + { + var paymentRequest = await paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber) + ?? throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Payment Request not found"); + + if (!paymentRequest.AccountCodingId.HasValue) + throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Account Coding - Payment Request - not found"); + + AccountCoding accountCoding = await accountCodingRepository.GetAsync(paymentRequest.AccountCodingId.Value); + string accountDistributionCode = await paymentConfigurationAppService.GetAccountDistributionCode(accountCoding); + + return new PaymentRequestData + { + PaymentRequest = paymentRequest, + AccountCoding = accountCoding, + AccountDistributionCode = accountDistributionCode + }; + } + + public async Task UpdatePaymentRequestWithInvoiceAsync(Guid paymentRequestId, InvoiceResponse invoiceResponse) + { + const int maxRetries = 3; + + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + // Each attempt must have a fresh UoW + using (var uow = unitOfWorkManager.Begin()) + { + // Load with tracking + var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId); + + if (paymentRequest == null) + { + Logger.LogWarning("PaymentRequest {Id} not found. Skipping update.", paymentRequestId); + return; + } + + // Idempotency: do not re-process + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.SentToCas) + { + Logger.LogInformation( + "PaymentRequest {Id} already invoiced. Skipping update.", + paymentRequestId + ); + return; + } + + // Apply CAS response info + paymentRequest.SetCasHttpStatusCode((int)invoiceResponse.CASHttpStatusCode); + paymentRequest.SetCasResponse(invoiceResponse.CASReturnedMessages); + + // Set status + paymentRequest.SetInvoiceStatus( + invoiceResponse.IsSuccess() + ? CasPaymentRequestStatus.SentToCas + : CasPaymentRequestStatus.ErrorFromCas + ); + + await paymentRequestRepository.UpdateAsync(paymentRequest, autoSave: false); + + // Commit this attempt + await uow.CompleteAsync(); + + Logger.LogInformation( + "PaymentRequest {Id} updated successfully on attempt {Attempt}.", + paymentRequestId, + attempt + ); + return; // success + } + } + catch (Exception ex) when ( + ex is AbpDbConcurrencyException || + ex is DbUpdateConcurrencyException + ) + { + Logger.LogWarning( + ex, + "Concurrency conflict when updating PaymentRequest {Id}, attempt {Attempt}", + paymentRequestId, + attempt + ); + + if (attempt == maxRetries) + { + Logger.LogError( + ex, + "Max retries reached for PaymentRequest {Id}. Manual intervention may be required.", + paymentRequestId + ); + + throw new UserFriendlyException( + $"Failed to update payment request {paymentRequestId} after {maxRetries} attempts due to concurrency conflicts." + ); + } + + // Brief pause before retrying to reduce immediate collision + await Task.Delay(75); + } + catch (Exception ex) + { + Logger.LogError( + ex, + "Unexpected exception updating PaymentRequest {Id} on attempt {Attempt}", + paymentRequestId, + attempt + ); + + throw new UserFriendlyException( + $"Failed to update payment request {paymentRequestId}: {ex.Message}" + ); + } + } + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestConfigurationManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestConfigurationManager.cs new file mode 100644 index 000000000..26a3bfa8a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestConfigurationManager.cs @@ -0,0 +1,157 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.Payments.Domain.PaymentConfigurations; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Domain.PaymentThresholds; +using Volo.Abp; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Domain.Services; + +namespace Unity.Payments.Domain.Services +{ + public class PaymentRequestConfigurationManager( + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IPaymentConfigurationRepository paymentConfigurationRepository, + IPaymentThresholdRepository paymentThresholdRepository, + IPaymentRequestRepository paymentRequestRepository) : DomainService, IPaymentRequestConfigurationManager + { + public async Task GetDefaultAccountCodingIdAsync() + { + Guid? accountCodingId = null; + // If no account coding is found look up the payment configuration + PaymentConfiguration? paymentConfiguration = await GetPaymentConfigurationAsync(); + if (paymentConfiguration != null && paymentConfiguration.DefaultAccountCodingId.HasValue) + { + accountCodingId = paymentConfiguration.DefaultAccountCodingId; + } + return accountCodingId; + } + + public async Task GetNextBatchInfoAsync() + { + var paymentConfig = await GetPaymentConfigurationAsync(); + var paymentIdPrefix = string.Empty; + + if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty()) + { + paymentIdPrefix = paymentConfig.PaymentIdPrefix; + } + + var batchNumber = await GetMaxBatchNumberAsync(); + var batchName = $"{paymentIdPrefix}_UNITY_BATCH_{batchNumber}"; + + return batchName; + } + + public string GenerateInvoiceNumber(string referenceNumber, string invoiceNumber, string sequencePart) + { + return $"{referenceNumber}-{invoiceNumber}-{sequencePart}"; + } + + public string GenerateReferenceNumber(string referenceNumber, string sequencePart) + { + return $"{referenceNumber}-{sequencePart}"; + } + + public string GenerateSequenceNumber(int sequenceNumber, int index) + { + sequenceNumber += index; + return sequenceNumber.ToString("D4"); + } + + public string GenerateReferenceNumberPrefix(string paymentIdPrefix) + { + var currentYear = DateTime.UtcNow.Year; + var yearPart = currentYear.ToString(); + return $"{paymentIdPrefix}-{yearPart}"; + } + + public async Task GetMaxBatchNumberAsync() + { + var paymentRequestList = await paymentRequestRepository.GetListAsync(); + decimal batchNumber = 1; // Lookup max plus 1 + if (paymentRequestList != null && paymentRequestList.Count > 0) + { + var maxBatchNumber = paymentRequestList.Max(s => s.BatchNumber); + + if (maxBatchNumber > 0) + { + batchNumber = maxBatchNumber + 1; + } + } + + return batchNumber; + } + + public async Task GetPaymentRequestThresholdByApplicationIdAsync(Guid applicationId, decimal? userPaymentThreshold) + { + var application = await (await applicationRepository.GetQueryableAsync()) + .Include(a => a.ApplicationForm) + .FirstOrDefaultAsync(a => a.Id == applicationId) ?? throw new BusinessException($"Application with Id {applicationId} not found."); + var appForm = application.ApplicationForm ?? + (application.ApplicationFormId != Guid.Empty + ? await applicationFormRepository.GetAsync(application.ApplicationFormId) + : null); + + var formThreshold = appForm?.PaymentApprovalThreshold; + + if (formThreshold.HasValue && userPaymentThreshold.HasValue) + { + return Math.Min(formThreshold.Value, userPaymentThreshold.Value); + } + + return formThreshold ?? userPaymentThreshold ?? 0m; + } + + public async Task GetUserPaymentThresholdAsync(Guid? userId) + { + var userThreshold = await paymentThresholdRepository.FirstOrDefaultAsync(x => x.UserId == userId); + return userThreshold?.Threshold; + } + + public async Task GetPaymentConfigurationAsync() + { + var paymentConfigs = await paymentConfigurationRepository.GetListAsync(); + + if (paymentConfigs.Count > 0) + { + var paymentConfig = paymentConfigs[0]; + return paymentConfig; + } + + return null; + } + + public async Task GetNextSequenceNumberAsync(int currentYear) + { + // Retrieve all payment requests + var payments = await paymentRequestRepository.GetListAsync(); + + // Filter payments for the current year + var filteredPayments = payments + .Where(p => p.CreationTime.Year == currentYear) + .OrderByDescending(p => p.CreationTime) + .ToList(); + + // Use the first payment in the sorted list (most recent) if available + if (filteredPayments.Count > 0) + { + var latestPayment = filteredPayments[0]; // Access the most recent payment directly + var referenceParts = latestPayment.ReferenceNumber.Split('-'); + + // Extract the sequence number from the reference number safely + if (referenceParts.Length > 0 && int.TryParse(referenceParts[^1], out int latestSequenceNumber)) + { + return latestSequenceNumber + 1; + } + } + + // If no payments exist or parsing fails, return the initial sequence number + return 1; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs new file mode 100644 index 000000000..f5fe8e972 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -0,0 +1,222 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Unity.Payments.Suppliers; +using Volo.Abp.Domain.Services; +using Volo.Abp.ObjectMapping; +using Volo.Abp.Users; + +namespace Unity.Payments.Domain.Services +{ + public class PaymentRequestQueryManager( + IPaymentRequestRepository paymentRequestRepository, + ISiteRepository siteRepository, + IExternalUserLookupServiceProvider externalUserLookupServiceProvider, + CasPaymentRequestCoordinator casPaymentRequestCoordinator, + IObjectMapper objectMapper) : DomainService, IPaymentRequestQueryManager + { + public Task GetPaymentRequestCountBySiteIdAsync(Guid siteId) + { + return paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); + } + + public async Task GetPaymentRequestCountAsync() + { + return await paymentRequestRepository.GetCountAsync(); + } + + public async Task GetPaymentRequestByIdAsync(Guid paymentRequestId) + { + return await paymentRequestRepository.GetAsync(paymentRequestId); + } + + public async Task> GetPaymentRequestsByIdsAsync(List paymentRequestIds, bool includeDetails = false) + { + return await paymentRequestRepository.GetListAsync(x => paymentRequestIds.Contains(x.Id), includeDetails: includeDetails); + } + + public async Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting) + { + await paymentRequestRepository.GetPagedListAsync(skipCount, maxResultCount, sorting, includeDetails: true); + + var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); +#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + var paymentWithIncludes = await paymentsQueryable + .Include(pr => pr.AccountCoding) + .Include(pr => pr.PaymentTags) + .ThenInclude(pt => pt.Tag) + .ToListAsync(); +#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + + return paymentWithIncludes; + } + + public async Task InsertPaymentRequestAsync(PaymentRequest paymentRequest) + { + return await paymentRequestRepository.InsertAsync(paymentRequest); + } + + public async Task CreatePaymentRequestDtoAsync(Guid paymentRequestId) + { + var payment = await paymentRequestRepository.GetAsync(paymentRequestId); + return new PaymentRequestDto + { + Id = payment.Id, + InvoiceNumber = payment.InvoiceNumber, + InvoiceStatus = payment.InvoiceStatus, + Amount = payment.Amount, + PayeeName = payment.PayeeName, + SupplierNumber = payment.SupplierNumber, + ContractNumber = payment.ContractNumber, + CorrelationId = payment.CorrelationId, + CorrelationProvider = payment.CorrelationProvider, + Description = payment.Description, + CreationTime = payment.CreationTime, + Status = payment.Status, + ReferenceNumber = payment.ReferenceNumber, + SubmissionConfirmationCode = payment.SubmissionConfirmationCode, + Note = payment.Note + }; + } + + public async Task> GetListByApplicationIdsAsync(List applicationIds) + { + var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); + var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); + var filteredPayments = payments.Where(pr => applicationIds.Contains(pr.CorrelationId)).ToList(); + + return objectMapper.Map, List>(filteredPayments); + } + + public async Task> GetListByApplicationIdAsync(Guid applicationId) + { + var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); + var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); + var filteredPayments = payments.Where(e => e.CorrelationId == applicationId).ToList(); + + return objectMapper.Map, List>(filteredPayments); + } + + public async Task> GetListByPaymentIdsAsync(List paymentIds) + { + var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); + var payments = await paymentsQueryable + .Where(e => paymentIds.Contains(e.Id)) + .Include(pr => pr.Site) + .Include(x => x.ExpenseApprovals) + .ToListAsync(); + + return objectMapper.Map, List>(payments); + } + + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId) + { + return await paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + } + + public async Task> MapToDtoAndLoadDetailsAsync(List paymentsList) + { + var paymentDtos = objectMapper.Map, List>(paymentsList); + + // Flatten all DecisionUserIds from ExpenseApprovals across all PaymentRequestDtos + List paymentRequesterIds = [.. paymentDtos + .Select(payment => payment.CreatorId) + .OfType() + .Distinct()]; + + List expenseApprovalCreatorIds = [.. paymentDtos + .SelectMany(payment => payment.ExpenseApprovals) + .Where(expenseApproval => expenseApproval.Status != ExpenseApprovalStatus.Requested) + .Select(expenseApproval => expenseApproval.DecisionUserId) + .OfType() + .Distinct()]; + + // Call external lookup for each distinct User Id and store in a dictionary. + var userDictionary = new Dictionary(); + var allUserIds = paymentRequesterIds.Concat(expenseApprovalCreatorIds).Distinct(); + foreach (var userId in allUserIds) + { + var userInfo = await externalUserLookupServiceProvider.FindByIdAsync(userId); + if (userInfo != null) + { + userDictionary[userId] = objectMapper.Map(userInfo); + } + } + + // Map UserInfo details to each ExpenseApprovalDto + foreach (var paymentRequestDto in paymentDtos) + { + if (paymentRequestDto.CreatorId.HasValue + && userDictionary.TryGetValue(paymentRequestDto.CreatorId.Value, out var paymentRequestUserDto)) + { + paymentRequestDto.CreatorUser = paymentRequestUserDto; + } + + if (paymentRequestDto.AccountCoding != null) + { + paymentRequestDto.AccountCodingDisplay = await GetAccountDistributionCodeAsync(paymentRequestDto.AccountCoding); + } + + if (paymentRequestDto.ExpenseApprovals != null) + { + foreach (var expenseApproval in paymentRequestDto.ExpenseApprovals) + { + if (expenseApproval.DecisionUserId.HasValue + && userDictionary.TryGetValue(expenseApproval.DecisionUserId.Value, out var expenseApprovalUserDto)) + { + expenseApproval.DecisionUser = expenseApprovalUserDto; + } + } + } + } + + return paymentDtos; + } + + public Task GetAccountDistributionCodeAsync(AccountCodingDto? accountCoding) + { + return Task.FromResult(AccountCodingFormatter.Format(accountCoding)); + } + + public void ApplyErrorSummary(List mappedPayments) + { + mappedPayments.ForEach(mappedPayment => + { + if (!string.IsNullOrWhiteSpace(mappedPayment.CasResponse) && + !mappedPayment.CasResponse.Equals("SUCCEEDED", StringComparison.OrdinalIgnoreCase)) + { + mappedPayment.ErrorSummary = mappedPayment.CasResponse; + } + }); + } + + public async Task ManuallyAddPaymentRequestsToReconciliationQueueAsync(List paymentRequestIds) + { + List paymentRequestDtos = []; + foreach (var paymentRequestId in paymentRequestIds) + { + var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId); + if (paymentRequest != null) + { + var paymentRequestDto = objectMapper.Map(paymentRequest); + Site site = await siteRepository.GetAsync(paymentRequest.SiteId); + paymentRequestDto.Site = objectMapper.Map(site); + paymentRequestDtos.Add(paymentRequestDto); + } + } + await casPaymentRequestCoordinator.ManuallyAddPaymentRequestsToReconciliationQueue(paymentRequestDtos); + } + + public async Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId) + { + var payments = await paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(applicationId); + return objectMapper.Map, List>(payments); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs index 503b32526..67cbc347e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs @@ -37,22 +37,21 @@ public class Supplier : FullAuditedAggregateRoot, IMultiTenant, ICorrelati protected Supplier() { /* This constructor is for ORMs to be used while getting the entity from the database. */ - Sites = new Collection(); + Sites = []; } public Supplier(Guid id, string? name, string? number, - Guid correlationId, - string correlationProvider, + Correlation correlation, MailingAddress? mailingAddress = default) : base(id) { Name = name; Number = number; - CorrelationId = correlationId; - CorrelationProvider = correlationProvider; - Sites = new Collection(); + CorrelationId = correlation.CorrelationId; + CorrelationProvider = correlation.CorrelationProvider; + Sites = []; MailingAddress = mailingAddress?.AddressLine; City = mailingAddress?.City; Province = mailingAddress?.Province; @@ -60,32 +59,26 @@ public Supplier(Guid id, } public Supplier(Guid id, - string? name, - string? number, - string? subcategory, - string? providerId, - string? businessNumber, - string? status, - string? supplierProtected, - string? standardIndustryClassification, - DateTime? lastUpdatedInCAS, - Guid correlationId, - string correlationProvider, + SupplierBasicInfo basicInfo, + Correlation correlation, + ProviderInfo? providerInfo = default, + SupplierStatus? supplierStatus = default, + CasMetadata? casMetadata = default, MailingAddress? mailingAddress = default) : base(id) { - Name = name; - Number = number; - Subcategory = subcategory; - ProviderId = providerId; - BusinessNumber = businessNumber; - Status = status; - SupplierProtected = supplierProtected; - StandardIndustryClassification = standardIndustryClassification; - LastUpdatedInCAS = lastUpdatedInCAS; - CorrelationId = correlationId; - CorrelationProvider = correlationProvider; - Sites = new Collection(); + Name = basicInfo.Name; + Number = basicInfo.Number; + Subcategory = basicInfo.Subcategory; + ProviderId = providerInfo?.ProviderId ?? ProviderId; + BusinessNumber = providerInfo?.BusinessNumber ?? BusinessNumber; + Status = supplierStatus?.Status ?? Status; + SupplierProtected = supplierStatus?.SupplierProtected ?? SupplierProtected; + StandardIndustryClassification = supplierStatus?.StandardIndustryClassification ?? StandardIndustryClassification; + LastUpdatedInCAS = casMetadata?.LastUpdatedInCAS ?? LastUpdatedInCAS; + CorrelationId = correlation.CorrelationId; + CorrelationProvider = correlation.CorrelationProvider; + Sites = []; MailingAddress = mailingAddress?.AddressLine; City = mailingAddress?.City; Province = mailingAddress?.Province; @@ -135,5 +128,38 @@ public void SetAddress(string? mailingAddress, Province = province; PostalCode = postalCode; } + + public void UpdateBasicInfo(SupplierBasicInfo basicInfo) + { + /* Business rules around updating basic supplier information */ + + Name = basicInfo.Name; + Number = basicInfo.Number; + Subcategory = basicInfo.Subcategory; + } + + public void UpdateProviderInfo(ProviderInfo providerInfo) + { + /* Business rules around updating provider information */ + + ProviderId = providerInfo.ProviderId; + BusinessNumber = providerInfo.BusinessNumber; + } + + public void UpdateStatus(SupplierStatus supplierStatus) + { + /* Business rules around updating supplier status */ + + Status = supplierStatus.Status; + SupplierProtected = supplierStatus.SupplierProtected; + StandardIndustryClassification = supplierStatus.StandardIndustryClassification; + } + + public void UpdateCasMetadata(CasMetadata casMetadata) + { + /* Business rules around updating CAS metadata */ + + LastUpdatedInCAS = casMetadata.LastUpdatedInCAS; + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/CasMetadata.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/CasMetadata.cs new file mode 100644 index 000000000..d56329a59 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/CasMetadata.cs @@ -0,0 +1,6 @@ +using System; + +namespace Unity.Payments.Domain.Suppliers.ValueObjects +{ + public record CasMetadata(DateTime? LastUpdatedInCAS = default); +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/ProviderInfo.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/ProviderInfo.cs new file mode 100644 index 000000000..3ceb8aca9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/ProviderInfo.cs @@ -0,0 +1,4 @@ +namespace Unity.Payments.Domain.Suppliers.ValueObjects +{ + public record ProviderInfo(string? ProviderId, string? BusinessNumber = default); +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierBasicInfo.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierBasicInfo.cs new file mode 100644 index 000000000..a2ac350ee --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierBasicInfo.cs @@ -0,0 +1,4 @@ +namespace Unity.Payments.Domain.Suppliers.ValueObjects +{ + public record SupplierBasicInfo(string? Name, string? Number, string? Subcategory = default); +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierStatus.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierStatus.cs new file mode 100644 index 000000000..2a9b5bc08 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierStatus.cs @@ -0,0 +1,6 @@ +namespace Unity.Payments.Domain.Suppliers.ValueObjects +{ + public record SupplierStatus(string? Status, + string? SupplierProtected = default, + string? StandardIndustryClassification = default); +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs index 962e3f3fa..6f1a35251 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs @@ -38,9 +38,15 @@ public static void ConfigurePayments( b.HasOne(e => e.AccountCoding) .WithMany() .HasForeignKey(x => x.AccountCodingId) - .OnDelete(DeleteBehavior.NoAction); - + .OnDelete(DeleteBehavior.NoAction); + b.HasIndex(e => e.ReferenceNumber).IsUnique(); + + // FSB Notification Tracking + b.Property(x => x.FsbNotificationEmailLogId).IsRequired(false); + b.Property(x => x.FsbNotificationSentDate).IsRequired(false); + b.Property(x => x.FsbApNotified).IsRequired(false).HasMaxLength(10); + b.HasIndex(x => x.FsbNotificationEmailLogId); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/FsbEmailSentEventHandler.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/FsbEmailSentEventHandler.cs new file mode 100644 index 000000000..5ab764889 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/FsbEmailSentEventHandler.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.Notifications.Events; +using Unity.Payments.Domain.PaymentRequests; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.Payments.Handlers +{ + /// + /// Handles FSB email sent events to update payment request tracking + /// + public class FsbEmailSentEventHandler : + ILocalEventHandler, + ITransientDependency + { + private readonly IPaymentRequestRepository _paymentRequestRepository; + private readonly ICurrentTenant _currentTenant; + private readonly IUnitOfWorkManager _unitOfWorkManager; + private readonly ILogger _logger; + + public FsbEmailSentEventHandler( + IPaymentRequestRepository paymentRequestRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + { + _paymentRequestRepository = paymentRequestRepository; + _currentTenant = currentTenant; + _unitOfWorkManager = unitOfWorkManager; + _logger = logger; + } + + public async Task HandleEventAsync(FsbEmailSentEto eventData) + { + using (_currentTenant.Change(eventData.TenantId)) + { + using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + + try + { + foreach (var paymentId in eventData.PaymentRequestIds) + { + try + { + var payment = await _paymentRequestRepository.GetAsync(paymentId, includeDetails: false); + payment.SetFsbNotificationEmailLog(eventData.EmailLogId, eventData.SentDate); + await _paymentRequestRepository.UpdateAsync(payment, autoSave: false); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update FSB notification tracking for payment {PaymentId}", + paymentId); + // Continue processing other payments + } + } + + await uow.SaveChangesAsync(); + await uow.CompleteAsync(); + + _logger.LogInformation( + "Updated FSB notification tracking for {Count} payments. EmailLogId: {EmailLogId}", + eventData.PaymentRequestIds.Count, + eventData.EmailLogId); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update FSB notification tracking for batch."); + throw new InvalidOperationException( + $"Failed to update FSB notification tracking for batch.", + ex); + } + } + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs index e00c99a19..9e7e70623 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs @@ -6,37 +6,25 @@ using Unity.Payments.Integrations.Http; using Volo.Abp.Application.Services; using System.Collections.Generic; -using Volo.Abp.Data; -using Microsoft.EntityFrameworkCore; using Unity.Payments.Enums; using Unity.Payments.Domain.Suppliers; using Unity.Payments.Domain.PaymentRequests; using Volo.Abp.DependencyInjection; -using Unity.Payments.Codes; using System.Net.Http; using Microsoft.Extensions.Logging; -using Volo.Abp.Uow; using Unity.Modules.Shared.Http; -using Unity.Payments.PaymentConfigurations; -using Unity.Payments.Domain.AccountCodings; using Unity.GrantManager.Integrations; +using Unity.Payments.Domain.Services; namespace Unity.Payments.Integrations.Cas { [IntegrationService] [ExposeServices(typeof(InvoiceService), typeof(IInvoiceService))] -#pragma warning disable S107 // Methods should not have too many parameters public class InvoiceService( IEndpointManagementAppService endpointManagementAppService, ICasTokenService iTokenService, - IAccountCodingRepository accountCodingRepository, - PaymentConfigurationAppService paymentConfigurationAppService, - IPaymentRequestRepository paymentRequestRepository, IResilientHttpRequest resilientHttpRequest, - ISupplierRepository iSupplierRepository, - ISiteRepository iSiteRepository, - IUnitOfWorkManager unitOfWorkManager) : ApplicationService, IInvoiceService -#pragma warning restore S107 // Methods should not have too many parameters + IInvoiceManager invoiceManager) : ApplicationService, IInvoiceService { private const string CFS_APINVOICE = "cfs/apinvoice"; @@ -50,7 +38,7 @@ public class InvoiceService( string? accountDistributionCode) { Invoice? casInvoice = new(); - Site? site = await GetSiteByPaymentRequestAsync(paymentRequest); + Site? site = await invoiceManager.GetSiteByPaymentRequestAsync(paymentRequest); if (site != null && site.Supplier != null && site.Supplier.Number != null && accountDistributionCode != null) { @@ -86,41 +74,23 @@ public class InvoiceService( return casInvoice; } - public async Task GetSiteByPaymentRequestAsync(PaymentRequest paymentRequest) - { - Site? site = await iSiteRepository.GetAsync(paymentRequest.SiteId, true); - if (site?.SupplierId != null) - { - Supplier supplier = await iSupplierRepository.GetAsync(site.SupplierId); - site.Supplier = supplier; - } - return site; - } - public async Task CreateInvoiceByPaymentRequestAsync(string invoiceNumber) { InvoiceResponse invoiceResponse = new(); try { - var paymentRequest = await paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber) - ?? throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Payment Request not found"); + var paymentRequestData = await invoiceManager.GetPaymentRequestDataAsync(invoiceNumber); - if (!paymentRequest.AccountCodingId.HasValue) - throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Account Coding - Payment Request - not found"); - - AccountCoding accountCoding = await accountCodingRepository.GetAsync(paymentRequest.AccountCodingId.Value); - string accountDistributionCode = await paymentConfigurationAppService.GetAccountDistributionCode(accountCoding);// this will be on the payment request - - if (!string.IsNullOrEmpty(accountDistributionCode)) + if (!string.IsNullOrEmpty(paymentRequestData.AccountDistributionCode)) { - Invoice? invoice = await InitializeCASInvoice(paymentRequest, accountDistributionCode); + Invoice? invoice = await InitializeCASInvoice(paymentRequestData.PaymentRequest, paymentRequestData.AccountDistributionCode); if (invoice is not null) { invoiceResponse = await CreateInvoiceAsync(invoice); if (invoiceResponse is not null) { - await UpdatePaymentRequestWithInvoice(paymentRequest.Id, invoiceResponse); + await invoiceManager.UpdatePaymentRequestWithInvoiceAsync(paymentRequestData.PaymentRequest.Id, invoiceResponse); } } } @@ -134,104 +104,6 @@ public class InvoiceService( return invoiceResponse; } - private async Task UpdatePaymentRequestWithInvoice(Guid paymentRequestId, InvoiceResponse invoiceResponse) - { - const int maxRetries = 3; - - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - // Each attempt must have a fresh UoW - using (var uow = unitOfWorkManager.Begin()) - { - // Load with tracking - var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId); - - if (paymentRequest == null) - { - Logger.LogWarning("PaymentRequest {Id} not found. Skipping update.", paymentRequestId); - return; - } - - // Idempotency: do not re-process - if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.SentToCas) - { - Logger.LogInformation( - "PaymentRequest {Id} already invoiced. Skipping update.", - paymentRequestId - ); - return; - } - - // Apply CAS response info - paymentRequest.SetCasHttpStatusCode((int)invoiceResponse.CASHttpStatusCode); - paymentRequest.SetCasResponse(invoiceResponse.CASReturnedMessages); - - // Set status - paymentRequest.SetInvoiceStatus( - invoiceResponse.IsSuccess() - ? CasPaymentRequestStatus.SentToCas - : CasPaymentRequestStatus.ErrorFromCas - ); - - await paymentRequestRepository.UpdateAsync(paymentRequest, autoSave: false); - - // Commit this attempt - await uow.CompleteAsync(); - - Logger.LogInformation( - "PaymentRequest {Id} updated successfully on attempt {Attempt}.", - paymentRequestId, - attempt - ); - return; // success - } - } - catch (Exception ex) when ( - ex is AbpDbConcurrencyException || - ex is DbUpdateConcurrencyException - ) { - Logger.LogWarning( - ex, - "Concurrency conflict when updating PaymentRequest {Id}, attempt {Attempt}", - paymentRequestId, - attempt - ); - - if (attempt == maxRetries) - { - Logger.LogError( - ex, - "Max retries reached for PaymentRequest {Id}. Manual intervention may be required.", - paymentRequestId - ); - - throw new UserFriendlyException( - $"Failed to update payment request {paymentRequestId} after {maxRetries} attempts due to concurrency conflicts." - ); - } - - // Brief pause before retrying to reduce immediate collision - await Task.Delay(75); - } - catch (Exception ex) - { - Logger.LogError( - ex, - "Unexpected exception updating PaymentRequest {Id} on attempt {Attempt}", - paymentRequestId, - attempt - ); - - throw new UserFriendlyException( - $"Failed to update payment request {paymentRequestId}: {ex.Message}" - ); - } - } - } - - public async Task CreateInvoiceAsync(Invoice casAPInvoice) { string jsonString = JsonSerializer.Serialize(casAPInvoice); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs index 235636cd1..f8ada042e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs @@ -15,7 +15,6 @@ namespace Unity.Payments.PaymentRequests; [DisallowConcurrentExecution] public class FinancialNotificationSummaryWorker : QuartzBackgroundWorkerBase { - private readonly ILogger _logger; private readonly FinancialSummaryNotifier _financialSummaryNotifier; private readonly IEnumerable _strategies; @@ -26,10 +25,9 @@ public FinancialNotificationSummaryWorker( IEnumerable strategies) { _financialSummaryNotifier = financialSummaryNotifier; - _logger = logger; _strategies = strategies; - _logger.LogInformation("FinancialNotificationSummary Constructor: Email strategies registered."); + logger.LogInformation("FinancialNotificationSummary Constructor: Email strategies registered."); string casFinancialNotificationExpression = ""; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs index 9d208c1fb..058916794 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs @@ -41,6 +41,14 @@ public class FsbPaymentExcelGenerator : ISingletonDependency private const string SheetName = "FSB Payments"; private const string DATE_FORMAT = "yyyy-MM-dd HH:mm:ss"; + private const string PacificTimeZoneId = "Pacific Standard Time"; + private static readonly string[] PacificTimeZoneIanaIds = + [ + "America/Vancouver", + "America/Los_Angeles" + ]; + private static readonly TimeZoneInfo PacificTimeZone = ResolvePacificTimeZone(); + private static readonly bool PacificTimeZoneIsUtcFallback = PacificTimeZone.Id == TimeZoneInfo.Utc.Id; /// /// Generates an Excel file from a list of FSB payment data @@ -112,21 +120,61 @@ private static void AddPaymentRow(IXLWorksheet worksheet, int rowNumber, FsbPaym worksheet.Cell(rowNumber, 3).Value = payment.PayeeName ?? "N/A"; worksheet.Cell(rowNumber, 4).Value = payment.CasSupplierSiteNumber ?? "N/A"; worksheet.Cell(rowNumber, 5).Value = payment.PayeeAddress ?? "N/A"; - worksheet.Cell(rowNumber, 6).Value = payment.InvoiceDate?.ToString(DATE_FORMAT) ?? "N/A"; + worksheet.Cell(rowNumber, 6).Value = FormatDate(payment.InvoiceDate); worksheet.Cell(rowNumber, 7).Value = payment.InvoiceNumber ?? "N/A"; worksheet.Cell(rowNumber, 8).Value = payment.Amount; worksheet.Cell(rowNumber, 9).Value = payment.PayGroup ?? "N/A"; - worksheet.Cell(rowNumber, 10).Value = payment.GoodsServicesReceivedDate?.ToString(DATE_FORMAT) ?? "N/A"; + worksheet.Cell(rowNumber, 10).Value = FormatDate(payment.GoodsServicesReceivedDate); worksheet.Cell(rowNumber, 11).Value = payment.QualifierReceiver ?? "N/A"; - worksheet.Cell(rowNumber, 12).Value = payment.QRApprovalDate?.ToString(DATE_FORMAT) ?? "N/A"; + worksheet.Cell(rowNumber, 12).Value = FormatDate(payment.QRApprovalDate); worksheet.Cell(rowNumber, 13).Value = payment.ExpenseAuthority ?? "N/A"; - worksheet.Cell(rowNumber, 14).Value = payment.EAApprovalDate?.ToString(DATE_FORMAT) ?? "N/A"; + worksheet.Cell(rowNumber, 14).Value = FormatDate(payment.EAApprovalDate); worksheet.Cell(rowNumber, 15).Value = payment.CasCheckStubDescription ?? "N/A"; worksheet.Cell(rowNumber, 16).Value = payment.AccountCoding ?? "N/A"; worksheet.Cell(rowNumber, 17).Value = payment.PaymentRequester ?? "N/A"; - worksheet.Cell(rowNumber, 18).Value = payment.RequestedOn?.ToString(DATE_FORMAT) ?? "N/A"; + worksheet.Cell(rowNumber, 18).Value = FormatDate(payment.RequestedOn); worksheet.Cell(rowNumber, 19).Value = payment.L3Approver ?? "N/A"; - worksheet.Cell(rowNumber, 20).Value = payment.L3ApprovalDate?.ToString(DATE_FORMAT) ?? "N/A"; + worksheet.Cell(rowNumber, 20).Value = FormatDate(payment.L3ApprovalDate); + } + + private static string FormatDate(DateTime? utcDateTime) + { + if (!utcDateTime.HasValue) + { + return "N/A"; + } + + var normalizedUtc = DateTime.SpecifyKind(utcDateTime.Value, DateTimeKind.Utc); + var pacificTime = TimeZoneInfo.ConvertTime(new DateTimeOffset(normalizedUtc), PacificTimeZone); + var tzAbbreviation = "UTC"; + if (!PacificTimeZoneIsUtcFallback) + { + tzAbbreviation = PacificTimeZone.IsDaylightSavingTime(pacificTime.DateTime) ? "PDT" : "PST"; + } + return $"{pacificTime.ToString(DATE_FORMAT)} {tzAbbreviation}"; + } + + private static TimeZoneInfo ResolvePacificTimeZone() + { + if (TimeZoneInfo.TryFindSystemTimeZoneById(PacificTimeZoneId, out var timeZone)) + { + return timeZone; + } + + return TryResolveIanaTimeZone(); + } + + private static TimeZoneInfo TryResolveIanaTimeZone() + { + foreach (var timeZoneId in PacificTimeZoneIanaIds) + { + if (TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out var timeZone)) + { + return timeZone; + } + } + + return TimeZoneInfo.Utc; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs index 1514720fc..ed1e18b70 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using Unity.Notifications.Emails; @@ -62,8 +63,6 @@ public async Task NotifyFsbPayments(List fsbPayments) try { - _logger.LogInformation("NotifyFsbPayments: Processing {Count} FSB payments", fsbPayments.Count); - // Get recipients from FSB-AP email group var recipients = await _fsbApEmailGroupStrategy.GetEmailRecipientsAsync(); if (recipients == null || recipients.Count == 0) @@ -72,17 +71,15 @@ public async Task NotifyFsbPayments(List fsbPayments) return; } - // Collect payment data for Excel - var paymentDataList = await CollectPaymentData(fsbPayments); - if (paymentDataList.Count == 0) - { - _logger.LogWarning("NotifyFsbPayments: Failed to collect payment data. Email not sent."); - return; - } + // Group payments by batch name (treating null/empty as "Unknown") + var batchGroups = fsbPayments + .GroupBy(p => string.IsNullOrWhiteSpace(p.BatchName) ? "Unknown" : p.BatchName) + .ToList(); - // Generate Excel file - byte[] excelBytes = FsbPaymentExcelGenerator.GenerateExcelFile(paymentDataList); - string fileName = $"FSB_Payments_{DateTime.UtcNow:yyyyMMdd_HHmmss}.xlsx"; + _logger.LogInformation( + "NotifyFsbPayments: Grouped {TotalPayments} payments into {BatchCount} batches", + fsbPayments.Count, + batchGroups.Count); // Get tenant name for email body string tenantName = "N/A"; @@ -92,43 +89,57 @@ public async Task NotifyFsbPayments(List fsbPayments) tenantName = tenant?.Name ?? "N/A"; } - // Generate email body - string emailBody = GenerateEmailBody(tenantName); - // Get from address var defaultFromAddress = await _settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); string fromAddress = defaultFromAddress ?? "NoReply@gov.bc.ca"; - // Publish email event with attachment - await _localEventBus.PublishAsync( - new EmailNotificationEvent + // Process each batch + int successCount = 0; + int failureCount = 0; + + foreach (var batchGroup in batchGroups) + { + string batchName = batchGroup.Key; + var batchPayments = batchGroup.ToList(); + + try + { + await SendBatchNotification( + batchName, + batchPayments, + recipients, + tenantName, + fromAddress); + + successCount++; + _logger.LogInformation( + "NotifyFsbPayments: Successfully sent notification for batch '{BatchName}' with {PaymentCount} payments", + batchName, + batchPayments.Count); + } + catch (Exception ex) { - Action = EmailAction.SendFsbNotification, - TenantId = _currentTenant.Id, - RetryAttempts = 0, - Body = emailBody, - Subject = "FSB Payment Notification", - EmailFrom = fromAddress, - EmailAddressList = recipients, - ApplicationId = Guid.Empty, // System-level email, not application-specific - EmailAttachments = - [ - new() { - FileName = fileName, - Content = excelBytes, // Byte array, not Base64 - ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - } - ] + failureCount++; + _logger.LogError( + ex, + "NotifyFsbPayments: Failed to send notification for batch '{BatchName}' with {PaymentCount} payments. Continuing with other batches.", + batchName, + batchPayments.Count); + // Continue processing other batches (resilient processing) } - ); + } - _logger.LogInformation("NotifyFsbPayments: Email notification published successfully for {Count} payments", fsbPayments.Count); + _logger.LogInformation( + "NotifyFsbPayments: Completed processing {TotalBatches} batches. Success: {SuccessCount}, Failed: {FailureCount}", + batchGroups.Count, + successCount, + failureCount); } catch (Exception ex) { - _logger.LogError(ex, "NotifyFsbPayments: Error sending FSB payment notification"); + _logger.LogError(ex, "NotifyFsbPayments: Critical error during batch processing"); throw new InvalidOperationException( - $"Failed to send FSB payment notification. See inner exception for details.", + $"Failed to process FSB payment notifications. See inner exception for details.", ex); } } @@ -275,7 +286,7 @@ private static string FormatPayGroup(PaymentGroup paymentGroup) private static void ApplyPaymentRequester( FsbPaymentData paymentData, PaymentRequest payment, - IReadOnlyDictionary userNameDict) + Dictionary userNameDict) { // Column 17: Payment Requester if (!payment.CreatorId.HasValue) @@ -424,5 +435,107 @@ private static string FormatAddress( return addressParts.Count > 0 ? string.Join(", ", addressParts) : "N/A"; } + /// + /// Sends email notification for a single batch of payments + /// + /// Name of the batch (already normalized for "Unknown") + /// List of payment requests in this batch + /// Email recipients list + /// Current tenant name for email body + /// Email from address + private async Task SendBatchNotification( + string batchName, + List batchPayments, + List recipients, + string tenantName, + string fromAddress) + { + // Collect payment data for this batch + var paymentDataList = await CollectPaymentData(batchPayments); + if (paymentDataList.Count == 0) + { + _logger.LogWarning( + "SendBatchNotification: Failed to collect payment data for batch '{BatchName}'. Email not sent.", + batchName); + return; + } + + // Generate Excel file + byte[] excelBytes = FsbPaymentExcelGenerator.GenerateExcelFile(paymentDataList); + + // Generate filename with sanitized batch name + string sanitizedBatchName = SanitizeFileName(batchName); + string fileName = $"FSB_Payments_{sanitizedBatchName}_{DateTime.UtcNow:yyyyMMdd_HHmmssfff}.xlsx"; + + // Generate email body (reuse existing method) + string emailBody = GenerateEmailBody(tenantName); + + // Generate email subject per requirement + string subject = batchName; + + // Extract payment IDs for tracking + var paymentIds = batchPayments.Select(p => p.Id).ToList(); + + // Publish email event with attachment + await _localEventBus.PublishAsync( + new EmailNotificationEvent + { + Action = EmailAction.SendFsbNotification, + TenantId = _currentTenant.Id, + RetryAttempts = 0, + Body = emailBody, + Subject = subject, // Batch-specific subject + EmailFrom = fromAddress, + EmailAddressList = recipients, + ApplicationId = Guid.Empty, + EmailAttachments = + [ + new() { + FileName = fileName, // Batch-specific filename + Content = excelBytes, + ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + ], + PaymentRequestIds = paymentIds // Track which payments are in this email + } + ); + } + + /// + /// Sanitizes batch name for use in filenames by removing invalid characters + /// + /// Original batch name + /// Sanitized batch name safe for filenames + private static string SanitizeFileName(string batchName) + { + if (string.IsNullOrWhiteSpace(batchName)) + { + return "Unknown"; + } + + // Get OS-specific invalid filename characters + char[] invalidChars = Path.GetInvalidFileNameChars(); + + // Replace invalid characters with underscore + string sanitized = batchName; + foreach (char c in invalidChars) + { + sanitized = sanitized.Replace(c, '_'); + } + + // Replace spaces with underscores for cleaner filenames + sanitized = sanitized.Replace(' ', '_'); + + // Trim to reasonable length (Windows has 255 char limit) + // Reserve space for: "FSB_Payments_" (13) + "_yyyyMMdd_HHmmssfff.xlsx" (25) = 38 chars + const int maxBatchNameLength = 217; // Conservative limit (255 - 38 = 217) + if (sanitized.Length > maxBatchNameLength) + { + sanitized = sanitized.Substring(0, maxBatchNameLength); + } + + return sanitized; + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index fc4946661..81c63c3b8 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -1,12 +1,10 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Unity.Payments.Domain.Exceptions; -using Unity.Payments.Domain.PaymentConfigurations; using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Services; using Unity.Payments.Domain.Shared; @@ -18,52 +16,32 @@ using Volo.Abp.Features; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Users; -using Unity.Payments.Domain.PaymentThresholds; -using Volo.Abp.Domain.Repositories; -using Unity.GrantManager.Applications; -using Unity.Payments.Domain.Suppliers; -using Unity.Payments.Suppliers; using Unity.Payments.PaymentRequests.Notifications; namespace Unity.Payments.PaymentRequests { [RequiresFeature("Unity.Payments")] [Authorize] - #pragma warning disable S107 // Suppress "Constructor has too many parameters" - public class PaymentRequestAppService( + public class PaymentRequestAppService( ICurrentUser currentUser, IDataFilter dataFilter, - IExternalUserLookupServiceProvider externalUserLookupServiceProvider, - IApplicationRepository applicationRepository, - IApplicationFormRepository applicationFormRepository, - IPaymentConfigurationRepository paymentConfigurationRepository, - IPaymentsManager paymentsManager, - IPaymentRequestRepository paymentRequestsRepository, - IPaymentThresholdRepository paymentThresholdRepository, IPermissionChecker permissionChecker, - ISiteRepository siteRepository, - CasPaymentRequestCoordinator casPaymentRequestCoordinator, - FsbPaymentNotifier fsbPaymentNotifier) : PaymentsAppService, IPaymentRequestAppService - #pragma warning restore S107 + IPaymentsManager paymentsManager, + FsbPaymentNotifier fsbPaymentNotifier, + IPaymentRequestQueryManager paymentRequestQueryManager, + IPaymentRequestConfigurationManager paymentRequestConfigurationManager) : PaymentsAppService, IPaymentRequestAppService { public async Task GetDefaultAccountCodingId() { - Guid? accountCodingId = null; - // If no account coding is found look up the payment configuration - PaymentConfiguration? paymentConfiguration = await GetPaymentConfigurationAsync(); - if (paymentConfiguration != null && paymentConfiguration.DefaultAccountCodingId.HasValue) - { - accountCodingId = paymentConfiguration.DefaultAccountCodingId; - } - return accountCodingId; + return await paymentRequestConfigurationManager.GetDefaultAccountCodingIdAsync(); } [Authorize(PaymentsPermissions.Payments.RequestPayment)] public virtual async Task> CreateAsync(List paymentRequests) { List createdPayments = []; - var paymentConfig = await GetPaymentConfigurationAsync(); + var paymentConfig = await paymentRequestConfigurationManager.GetPaymentConfigurationAsync(); var paymentIdPrefix = string.Empty; if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty()) @@ -71,10 +49,10 @@ public virtual async Task> CreateAsync(List new { i, value })) { @@ -82,18 +60,18 @@ public virtual async Task> CreateAsync(List> CreateAsync(List GetNextBatchInfoAsync() { - var paymentConfig = await GetPaymentConfigurationAsync(); - var paymentIdPrefix = string.Empty; - - if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty()) - { - paymentIdPrefix = paymentConfig.PaymentIdPrefix; - } - - var batchNumber = await GetMaxBatchNumberAsync(); - var batchName = $"{paymentIdPrefix}_UNITY_BATCH_{batchNumber}"; - - return batchName; - } - - private static string GenerateInvoiceNumberAsync(string referenceNumber, string invoiceNumber, string sequencePart) - { - return $"{referenceNumber}-{invoiceNumber}-{sequencePart}"; - } - - private static string GenerateReferenceNumberAsync(string referenceNumber, string sequencePart) - { - return $"{referenceNumber}-{sequencePart}"; - } - - - private static string GenerateSequenceNumberAsync(int sequenceNumber, int index) - { - sequenceNumber += index; - return sequenceNumber.ToString("D4"); - } - - private static string GenerateReferenceNumberPrefixAsync(string paymentIdPrefix) - { - var currentYear = DateTime.UtcNow.Year; - var yearPart = currentYear.ToString(); - return $"{paymentIdPrefix}-{yearPart}"; - } - - private async Task GetMaxBatchNumberAsync() - { - var paymentRequestList = await paymentRequestsRepository.GetListAsync(); - decimal batchNumber = 1; // Lookup max plus 1 - if (paymentRequestList != null && paymentRequestList.Count > 0) - { - var maxBatchNumber = paymentRequestList.Max(s => s.BatchNumber); - - if (maxBatchNumber > 0) - { - batchNumber = maxBatchNumber + 1; - } - } - - return batchNumber; + return await paymentRequestConfigurationManager.GetNextBatchInfoAsync(); } public Task GetPaymentRequestCountBySiteIdAsync(Guid siteId) { - return paymentRequestsRepository.GetPaymentRequestCountBySiteId(siteId); + return paymentRequestQueryManager.GetPaymentRequestCountBySiteIdAsync(siteId); } public virtual async Task> UpdateStatusAsync(List paymentRequests) @@ -189,10 +115,10 @@ public virtual async Task> UpdateStatusAsync(List r.IsApprove).Select(x => x.PaymentRequestId).ToList(); - var approvalList = await paymentRequestsRepository.GetListAsync(x => approvalRequests.Contains(x.Id), includeDetails: true); + var approvalList = await paymentRequestQueryManager.GetPaymentRequestsByIdsAsync(approvalRequests, includeDetails: true); // Rule AB#26693: Reject Payment Request update batch if violates L1 and L2 separation of duties - if (approvalList.Any( + if (approvalList.Exists( x => x.Status == PaymentRequestStatus.L2Pending && CurrentUser.Id == x.ExpenseApprovals.FirstOrDefault(y => y.Type == ExpenseApprovalType.Level1)?.DecisionUserId)) { @@ -205,7 +131,7 @@ public virtual async Task> UpdateStatusAsync(List> UpdateStatusAsync(List> UpdateStatusAsync(List fsbPaymentIds.Contains(p.Id), - includeDetails: true); + var fsbPayments = await paymentRequestQueryManager.GetPaymentRequestsByIdsAsync(fsbPaymentIds, includeDetails: true); await fsbPaymentNotifier.NotifyFsbPayments(fsbPayments); } @@ -297,12 +221,12 @@ private async Task GetLevel2ApprovalActionAsync(UpdatePay { if (!dto.IsApprove) return PaymentApprovalAction.L2Decline; - + decimal? threshold = null; try { decimal? userPaymentThreshold = await GetUserPaymentThresholdAsync(); - threshold = await GetPaymentRequestThresholdByApplicationIdAsync(payment.CorrelationId, userPaymentThreshold); + threshold = await paymentRequestConfigurationManager.GetPaymentRequestThresholdByApplicationIdAsync(payment.CorrelationId, userPaymentThreshold); } catch (Exception ex) { @@ -314,26 +238,12 @@ private async Task GetLevel2ApprovalActionAsync(UpdatePay return PaymentApprovalAction.Submit; } + public async Task GetPaymentRequestThresholdByApplicationIdAsync(Guid applicationId, decimal? userPaymentThreshold = null) { - var application = await (await applicationRepository.GetQueryableAsync()) - .Include(a => a.ApplicationForm) - .FirstOrDefaultAsync(a => a.Id == applicationId) ?? throw new BusinessException($"Application with Id {applicationId} not found."); - var appForm = application.ApplicationForm ?? - (application.ApplicationFormId != Guid.Empty - ? await applicationFormRepository.GetAsync(application.ApplicationFormId) - : null); - - var formThreshold = appForm?.PaymentApprovalThreshold; - - if (formThreshold.HasValue && userPaymentThreshold.HasValue) - { - return Math.Min(formThreshold.Value, userPaymentThreshold.Value); - } - - return formThreshold ?? userPaymentThreshold ?? 0m; + return await paymentRequestConfigurationManager.GetPaymentRequestThresholdByApplicationIdAsync(applicationId, userPaymentThreshold); } - + private async Task CanPerformLevel1ActionAsync(PaymentRequestStatus status) { List level1Approvals = [PaymentRequestStatus.L1Pending, PaymentRequestStatus.L1Declined]; @@ -361,174 +271,47 @@ private async Task CanPerformLevel3ActionAsync(PaymentRequestStatus status return await permissionChecker.IsGrantedAsync(PaymentsPermissions.Payments.L3ApproveOrDecline) && level3Approvals.Contains(status); } - private async Task CreatePaymentRequestDtoAsync(Guid paymentRequestId) - { - var payment = await paymentRequestsRepository.GetAsync(paymentRequestId); - return new PaymentRequestDto - { - Id = payment.Id, - InvoiceNumber = payment.InvoiceNumber, - InvoiceStatus = payment.InvoiceStatus, - Amount = payment.Amount, - PayeeName = payment.PayeeName, - SupplierNumber = payment.SupplierNumber, - ContractNumber = payment.ContractNumber, - CorrelationId = payment.CorrelationId, - CorrelationProvider = payment.CorrelationProvider, - Description = payment.Description, - CreationTime = payment.CreationTime, - Status = payment.Status, - ReferenceNumber = payment.ReferenceNumber, - SubmissionConfirmationCode = payment.SubmissionConfirmationCode, - Note = payment.Note - }; - } - public async Task> GetListByApplicationIdsAsync(List applicationIds) { - var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync(); - var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); - var filteredPayments = payments.Where(pr => applicationIds.Contains(pr.CorrelationId)).ToList(); - - return ObjectMapper.Map, List>(filteredPayments); + return await paymentRequestQueryManager.GetListByApplicationIdsAsync(applicationIds); } public async Task> GetListAsync(PagedAndSortedResultRequestDto input) { - var totalCount = await paymentRequestsRepository.GetCountAsync(); + var totalCount = await paymentRequestQueryManager.GetPaymentRequestCountAsync(); using (dataFilter.Disable()) { - await paymentRequestsRepository - .GetPagedListAsync(input.SkipCount, input.MaxResultCount, input.Sorting ?? string.Empty, includeDetails: true); - - // Include PaymentTags in the query - var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync(); - // Changing this breaks the code so suppressing the warning -#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. - var paymentWithIncludes = await paymentsQueryable - .Include(pr => pr.AccountCoding) - .Include(pr => pr.PaymentTags) - .ThenInclude(pt => pt.Tag) - .ToListAsync(); -#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + var paymentWithIncludes = await paymentRequestQueryManager.GetPagedPaymentRequestsWithIncludesAsync(input.SkipCount, input.MaxResultCount, input.Sorting ?? string.Empty); - var mappedPayments = await MapToDtoAndLoadDetailsAsync(paymentWithIncludes); + var mappedPayments = await paymentRequestQueryManager.MapToDtoAndLoadDetailsAsync(paymentWithIncludes); - ApplyErrorSummary(mappedPayments); + paymentRequestQueryManager.ApplyErrorSummary(mappedPayments); return new PagedResultDto(totalCount, mappedPayments); } } - protected internal async Task> MapToDtoAndLoadDetailsAsync(List paymentsList) - { - var paymentDtos = ObjectMapper.Map, List>(paymentsList); - - // Flatten all DecisionUserIds from ExpenseApprovals across all PaymentRequestDtos - List paymentRequesterIds = [.. paymentDtos - .Select(payment => payment.CreatorId) - .OfType() - .Distinct()]; - - List expenseApprovalCreatorIds = [.. paymentDtos - .SelectMany(payment => payment.ExpenseApprovals) - .Where(expenseApproval => expenseApproval.Status != ExpenseApprovalStatus.Requested) - .Select(expenseApproval => expenseApproval.DecisionUserId) - .OfType() - .Distinct()]; - - // Call external lookup for each distinct User Id and store in a dictionary. - var userDictionary = new Dictionary(); - var allUserIds = paymentRequesterIds.Concat(expenseApprovalCreatorIds).Distinct(); - foreach (var userId in allUserIds) - { - var userInfo = await externalUserLookupServiceProvider.FindByIdAsync(userId); - if (userInfo != null) - { - userDictionary[userId] = ObjectMapper.Map(userInfo); - } - } - - // Map UserInfo details to each ExpenseApprovalDto - foreach (var paymentRequestDto in paymentDtos) - { - if (paymentRequestDto.CreatorId.HasValue - && userDictionary.TryGetValue(paymentRequestDto.CreatorId.Value, out var paymentRequestUserDto)) - { - paymentRequestDto.CreatorUser = paymentRequestUserDto; - } - - if(paymentRequestDto != null && paymentRequestDto.AccountCoding != null) - { - paymentRequestDto.AccountCodingDisplay = await GetAccountDistributionCode(paymentRequestDto.AccountCoding); - } - - if (paymentRequestDto != null && paymentRequestDto.ExpenseApprovals != null) - { - foreach (var expenseApproval in paymentRequestDto.ExpenseApprovals) - { - if (expenseApproval.DecisionUserId.HasValue - && userDictionary.TryGetValue(expenseApproval.DecisionUserId.Value, out var expenseApprovalUserDto)) - { - expenseApproval.DecisionUser = expenseApprovalUserDto; - } - } - } - } - - return paymentDtos; - } - - public virtual Task GetAccountDistributionCode(AccountCodingDto? accountCoding) - { - return Task.FromResult(AccountCodingFormatter.Format(accountCoding)); - } - - private static void ApplyErrorSummary(List mappedPayments) - { - mappedPayments.ForEach(mappedPayment => - { - if (!string.IsNullOrWhiteSpace(mappedPayment.CasResponse) && - !mappedPayment.CasResponse.Equals("SUCCEEDED", StringComparison.OrdinalIgnoreCase)) - { - mappedPayment.ErrorSummary = mappedPayment.CasResponse; - } - }); - } - public async Task> GetListByApplicationIdAsync(Guid applicationId) { using (dataFilter.Disable()) { - var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync(); - var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); - var filteredPayments = payments.Where(e => e.CorrelationId == applicationId).ToList(); - - return ObjectMapper.Map, List>(filteredPayments); + return await paymentRequestQueryManager.GetListByApplicationIdAsync(applicationId); } } public async Task> GetListByPaymentIdsAsync(List paymentIds) { - var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync(); - var payments = await paymentsQueryable - .Where(e => paymentIds.Contains(e.Id)) - .Include(pr => pr.Site) - .Include(x => x.ExpenseApprovals) - .ToListAsync(); - - return ObjectMapper.Map, List>(payments); + return await paymentRequestQueryManager.GetListByPaymentIdsAsync(paymentIds); } public virtual async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId) { - return await paymentRequestsRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + return await paymentRequestQueryManager.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); } public async Task GetUserPaymentThresholdAsync() { - var userThreshold = await paymentThresholdRepository.FirstOrDefaultAsync(x => x.UserId == currentUser.Id); - return userThreshold?.Threshold; + return await paymentRequestConfigurationManager.GetUserPaymentThresholdAsync(currentUser.Id); } protected virtual string GetCurrentRequesterName() @@ -536,62 +319,14 @@ protected virtual string GetCurrentRequesterName() return $"{currentUser.Name} {currentUser.SurName}"; } - protected virtual async Task GetPaymentConfigurationAsync() - { - var paymentConfigs = await paymentConfigurationRepository.GetListAsync(); - - if (paymentConfigs.Count > 0) - { - var paymentConfig = paymentConfigs[0]; - return paymentConfig; - } - - return null; - } - public async Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds) { - List paymentRequestDtos = []; - foreach (var paymentRequestId in paymentRequestIds) - { - var paymentRequest = await paymentRequestsRepository.GetAsync(paymentRequestId); - if (paymentRequest != null) - { - var paymentRequestDto = ObjectMapper.Map(paymentRequest); - Site site = await siteRepository.GetAsync(paymentRequest.SiteId); - paymentRequestDto.Site = ObjectMapper.Map(site); - paymentRequestDtos.Add(paymentRequestDto); - } - } - await casPaymentRequestCoordinator.ManuallyAddPaymentRequestsToReconciliationQueue(paymentRequestDtos); + await paymentRequestQueryManager.ManuallyAddPaymentRequestsToReconciliationQueueAsync(paymentRequestIds); } - - private async Task GetNextSequenceNumberAsync(int currentYear) - { - // Retrieve all payment requests - var payments = await paymentRequestsRepository.GetListAsync(); - - // Filter payments for the current year - var filteredPayments = payments - .Where(p => p.CreationTime.Year == currentYear) - .OrderByDescending(p => p.CreationTime) - .ToList(); - - // Use the first payment in the sorted list (most recent) if available - if (filteredPayments.Count > 0) - { - var latestPayment = filteredPayments[0]; // Access the most recent payment directly - var referenceParts = latestPayment.ReferenceNumber.Split('-'); - - // Extract the sequence number from the reference number safely - if (referenceParts.Length > 0 && int.TryParse(referenceParts[^1], out int latestSequenceNumber)) - { - return latestSequenceNumber + 1; - } - } - // If no payments exist or parsing fails, return the initial sequence number - return 1; + public async Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId) + { + return await paymentRequestQueryManager.GetPaymentPendingListByCorrelationIdAsync(applicationId); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs index 9aade76e2..d173f9b2f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.Application.Services; using Unity.Payments.Enums; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs index c9a2b7b7f..9b6b1bf2c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using System; using System.Collections.Generic; using System.Linq; @@ -12,6 +11,7 @@ using Unity.Payments.Enums; using Unity.Payments.Integrations.Cas; using Volo.Abp.Features; +using Unity.Modules.Shared.Correlation; namespace Unity.Payments.Suppliers { @@ -20,27 +20,40 @@ public class SupplierAppService(ISupplierRepository supplierRepository, ISupplierService supplierService, ISiteAppService siteAppService, IApplicationRepository applicationRepository) : PaymentsAppService, ISupplierAppService - { - protected ILogger logger => LazyServiceProvider.LazyGetService(provider => LoggerFactory?.CreateLogger(GetType().FullName!) ?? NullLogger.Instance); - + { public virtual async Task CreateAsync(CreateSupplierDto createSupplierDto) { - Supplier supplier = new Supplier(Guid.NewGuid(), - createSupplierDto.Name, - createSupplierDto.Number, - createSupplierDto.Subcategory, - createSupplierDto.ProviderId, - createSupplierDto.BusinessNumber, - createSupplierDto.Status, - createSupplierDto.SupplierProtected, - createSupplierDto.StandardIndustryClassification, - createSupplierDto.LastUpdatedInCAS, - createSupplierDto.CorrelationId, - createSupplierDto.CorrelationProvider, - new MailingAddress(createSupplierDto.MailingAddress, - createSupplierDto.City, - createSupplierDto.Province, - createSupplierDto.PostalCode)); + var basicInfo = new SupplierBasicInfo( + createSupplierDto.Name, + createSupplierDto.Number, + createSupplierDto.Subcategory); + + var providerInfo = new ProviderInfo( + createSupplierDto.ProviderId, + createSupplierDto.BusinessNumber); + + var supplierStatus = new SupplierStatus( + createSupplierDto.Status, + createSupplierDto.SupplierProtected, + createSupplierDto.StandardIndustryClassification); + + var casMetadata = new CasMetadata(createSupplierDto.LastUpdatedInCAS); + + var correlation = new Correlation(createSupplierDto.CorrelationId, createSupplierDto.CorrelationProvider); + + var mailingAddress = new MailingAddress( + createSupplierDto.MailingAddress, + createSupplierDto.City, + createSupplierDto.Province, + createSupplierDto.PostalCode); + + Supplier supplier = new(Guid.NewGuid(), + basicInfo, + correlation, + providerInfo, + supplierStatus, + casMetadata, + mailingAddress); var result = await supplierRepository.InsertAsync(supplier); return ObjectMapper.Map(result); @@ -49,15 +62,24 @@ public virtual async Task CreateAsync(CreateSupplierDto createSuppl public virtual async Task UpdateAsync(Guid id, UpdateSupplierDto updateSupplierDto) { var supplier = await supplierRepository.GetAsync(id); - supplier.Name = updateSupplierDto.Name; - supplier.Number = updateSupplierDto.Number; - supplier.Subcategory = updateSupplierDto.Subcategory; - supplier.ProviderId = updateSupplierDto.ProviderId; - supplier.BusinessNumber = updateSupplierDto.BusinessNumber; - supplier.Status = updateSupplierDto.Status; - supplier.SupplierProtected = updateSupplierDto.SupplierProtected; - supplier.StandardIndustryClassification = updateSupplierDto.StandardIndustryClassification; - supplier.LastUpdatedInCAS = updateSupplierDto.LastUpdatedInCAS; + + // Use the new value object methods for better encapsulation + supplier.UpdateBasicInfo(new SupplierBasicInfo( + updateSupplierDto.Name, + updateSupplierDto.Number, + updateSupplierDto.Subcategory)); + + supplier.UpdateProviderInfo(new ProviderInfo( + updateSupplierDto.ProviderId, + updateSupplierDto.BusinessNumber)); + + supplier.UpdateStatus(new SupplierStatus( + updateSupplierDto.Status, + updateSupplierDto.SupplierProtected, + updateSupplierDto.StandardIndustryClassification)); + + supplier.UpdateCasMetadata(new CasMetadata(updateSupplierDto.LastUpdatedInCAS)); + supplier.CorrelationId = updateSupplierDto.CorrelationId; supplier.CorrelationProvider = updateSupplierDto.CorrelationProvider; @@ -80,7 +102,7 @@ public virtual async Task UpdateAsync(Guid id, UpdateSupplierDto up } catch (Exception ex) { - logger.LogError(ex, "Error fetching supplier"); + Logger.LogError(ex, "Error fetching supplier"); return null; } } @@ -128,7 +150,7 @@ public async Task GetSitesBySupplierNumberAsync(string? supplierNumber, var supplier = await GetBySupplierNumberAsync(supplierNumber); if (supplier == null) return new List(); List sites = await siteAppService.GetSitesBySupplierIdAsync(supplier.Id); - List existingSiteDtos = sites.Select(ObjectMapper.Map).ToList(); + List existingSiteDtos = [.. sites.Select(ObjectMapper.Map)]; bool hasChanges = false; // If the list of CAS sites is different from the existing sites @@ -184,7 +206,7 @@ public async Task GetSitesBySupplierNumberAsync(string? supplierNumber, if (updatedSupplier != null) { List updatedSites = await siteAppService.GetSitesBySupplierIdAsync(updatedSupplier.Id); - existingSiteDtos = updatedSites.Select(ObjectMapper.Map).ToList(); + existingSiteDtos = [.. updatedSites.Select(ObjectMapper.Map)]; } } @@ -289,7 +311,7 @@ private async Task ResolveDefaultPaymentGroupForApplicantAsync(Gui } catch (Exception ex) { - logger.LogWarning(ex, "Unable to resolve default payment group for applicant {ApplicantId}", applicantId); + Logger.LogWarning(ex, "Unable to resolve default payment group for applicant {ApplicantId}", applicantId); } return fallbackPaymentGroup; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json index 6d2d0da0b..7e9985a4e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json @@ -61,6 +61,7 @@ "ApplicationPaymentListTable:InvoiceStatus": "Invoice Status", "ApplicationPaymentListTable:PaymentStatus": "CAS Payment Status", "ApplicationPaymentListTable:Note": "Note", + "ApplicationPaymentListTable:FsbApNotified": "FSB-AP Notified", "ApplicantInfoView:SupplierInfoTitle": "Supplier Info", "ApplicantInfoView:ApplicantInfo:SupplierNumber": "Supplier #", diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.css b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.css index e02abfc9b..104dd8029 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.css +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.css @@ -1 +1 @@ - +/* Placeholder file required by the component structure */ diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js index d40a74c37..c9a8f1315 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js @@ -8,16 +8,15 @@ function removeApplicationPaymentRequest(applicationId) { let applicationCount = $('#ApplicationCount').val(); $('#ApplicationCount').val(applicationCount - 1); - if ((applicationCount - 1) == 1) { - $('.max-error').css("display", "none"); - $('.payment-divider').css("display", "none"); + if (applicationCount - 1 == 1) { + $('.max-error').css('display', 'none'); + $('.payment-divider').css('display', 'none'); } if (!$('div.single-payment').length) { - $('#no-payment-msg').css("display", "block"); - $("#payment-modal").find('#btnSubmitPayment').prop("disabled", true); - } - else { - $('#no-payment-msg').css("display", "none"); + $('#no-payment-msg').css('display', 'block'); + $('#payment-modal').find('#btnSubmitPayment').prop('disabled', true); + } else { + $('#no-payment-msg').css('display', 'none'); } // Always recalculate the total after removal @@ -38,12 +37,12 @@ function checkMaxValueRequest(applicationId, input, amountRemaining) { validateParentChildAmounts(applicationId); } else { // Use existing remaining amount validation - let enteredValue = parseFloat(input.value.replace(/,/g, "")); - let remainingErrorId = "#column_" + applicationId + "_remaining_error"; + let enteredValue = parseFloat(input.value.replace(/,/g, '')); + let remainingErrorId = '#column_' + applicationId + '_remaining_error'; if (amountRemaining < enteredValue) { - $(remainingErrorId).css("display", "block"); + $(remainingErrorId).css('display', 'block'); } else { - $(remainingErrorId).css("display", "none"); + $(remainingErrorId).css('display', 'none'); } } @@ -53,25 +52,35 @@ function checkMaxValueRequest(applicationId, input, amountRemaining) { function validateAllPaymentAmounts() { // Iterate through all payment requests - $('input[name*=".CorrelationId"]').each(function() { + $('input[name*=".CorrelationId"]').each(function () { let correlationId = $(this).val(); let index = getIndexByCorrelationId(correlationId); - let isPartOfGroup = $(`input[name="ApplicationPaymentRequestForm[${index}].IsPartOfParentChildGroup"]`).val() === 'True'; + let isPartOfGroup = + $( + `input[name="ApplicationPaymentRequestForm[${index}].IsPartOfParentChildGroup"]` + ).val() === 'True'; if (isPartOfGroup) { // Validate parent-child amounts validateParentChildAmounts(correlationId); } else { // Validate standalone payment against remaining amount - let amountInput = $(`input[name="ApplicationPaymentRequestForm[${index}].Amount"]`); - let remainingAmount = parseFloat($(`input[name="ApplicationPaymentRequestForm[${index}].RemainingAmount"]`).val()); - let enteredValue = parseFloat(amountInput.val().replace(/,/g, '')) || 0; + let amountInput = $( + `input[name="ApplicationPaymentRequestForm[${index}].Amount"]` + ); + let remainingAmount = parseFloat( + $( + `input[name="ApplicationPaymentRequestForm[${index}].RemainingAmount"]` + ).val() + ); + let enteredValue = + parseFloat(amountInput.val().replace(/,/g, '')) || 0; let remainingErrorId = `#column_${correlationId}_remaining_error`; if (enteredValue > remainingAmount) { - $(remainingErrorId).css("display", "block"); + $(remainingErrorId).css('display', 'block'); } else { - $(remainingErrorId).css("display", "none"); + $(remainingErrorId).css('display', 'none'); } } }); @@ -82,7 +91,7 @@ function submitPayments() { validateAllPaymentAmounts(); // check for error class divs - let validationFailed = $(".payment-error-column:visible").length > 0; + let validationFailed = $('.payment-error-column:visible').length > 0; if (validationFailed) { abp.notify.error( @@ -93,7 +102,7 @@ function submitPayments() { } else { $('#paymentform').submit(); } -}; +} function calculateTotalAmount() { let total = 0; @@ -103,17 +112,19 @@ function calculateTotalAmount() { }); let totalFormatted = createPaymentNumberFormatter.format(total); - $('#TotalAmount').val(totalFormatted); + $('#TotalAmount').val(totalFormatted); } function getIndexByCorrelationId(correlationId) { // Find the index of the payment request by CorrelationId let index = -1; - $('input[name*=".CorrelationId"]').each(function() { + $('input[name*=".CorrelationId"]').each(function () { if ($(this).val() === correlationId) { // Extract the actual index from the name attribute // e.g., "ApplicationPaymentRequestForm[2].CorrelationId" -> "2" - let match = $(this).attr('name').match(/\[(\d+)\]/); + let match = $(this) + .attr('name') + .match(/\[(\d+)\]/); if (match) { index = parseInt(match[1], 10); } @@ -127,7 +138,9 @@ function isPartOfParentChildGroup(correlationId) { let index = getIndexByCorrelationId(correlationId); if (index === -1) return false; - let input = $(`input[name="ApplicationPaymentRequestForm[${index}].IsPartOfParentChildGroup"]`); + let input = $( + `input[name="ApplicationPaymentRequestForm[${index}].IsPartOfParentChildGroup"]` + ); return input.val() === 'True'; } @@ -138,17 +151,27 @@ function formatCurrency(value) { typeof value === 'number' ? value : parseFloat(String(value ?? '').replace(/,/g, '')); - return cadFormatter.format(Number.isFinite(numericValue) ? numericValue : 0); + return cadFormatter.format( + Number.isFinite(numericValue) ? numericValue : 0 + ); } function validateParentChildAmounts(correlationId) { let index = getIndexByCorrelationId(correlationId); if (index === -1) return; - let parentRefNo = $(`input[name="ApplicationPaymentRequestForm[${index}].ParentReferenceNo"]`).val(); - let submissionCode = $(`input[name="ApplicationPaymentRequestForm[${index}].SubmissionConfirmationCode"]`).val(); - let maximumAllowedInput = $(`input[name="ApplicationPaymentRequestForm[${index}].MaximumAllowedAmount"]`).val(); - let maximumAllowed = maximumAllowedInput ? parseFloat(maximumAllowedInput) : 0; + let parentRefNo = $( + `input[name="ApplicationPaymentRequestForm[${index}].ParentReferenceNo"]` + ).val(); + let submissionCode = $( + `input[name="ApplicationPaymentRequestForm[${index}].SubmissionConfirmationCode"]` + ).val(); + let maximumAllowedInput = $( + `input[name="ApplicationPaymentRequestForm[${index}].MaximumAllowedAmount"]` + ).val(); + let maximumAllowed = maximumAllowedInput + ? parseFloat(maximumAllowedInput) + : 0; // Determine if this is a parent or child let isChild = parentRefNo && parentRefNo.trim() !== ''; @@ -158,13 +181,20 @@ function validateParentChildAmounts(correlationId) { let groupTotal = 0; let groupMembers = []; - $('input[name*=".CorrelationId"]').each(function() { + $('input[name*=".CorrelationId"]').each(function () { let itemCorrelationId = $(this).val(); let itemIndex = getIndexByCorrelationId(itemCorrelationId); - let itemParentRefNo = $(`input[name="ApplicationPaymentRequestForm[${itemIndex}].ParentReferenceNo"]`).val(); - let itemSubmissionCode = $(`input[name="ApplicationPaymentRequestForm[${itemIndex}].SubmissionConfirmationCode"]`).val(); - let itemIsPartOfGroup = $(`input[name="ApplicationPaymentRequestForm[${itemIndex}].IsPartOfParentChildGroup"]`).val() === 'True'; + let itemParentRefNo = $( + `input[name="ApplicationPaymentRequestForm[${itemIndex}].ParentReferenceNo"]` + ).val(); + let itemSubmissionCode = $( + `input[name="ApplicationPaymentRequestForm[${itemIndex}].SubmissionConfirmationCode"]` + ).val(); + let itemIsPartOfGroup = + $( + `input[name="ApplicationPaymentRequestForm[${itemIndex}].IsPartOfParentChildGroup"]` + ).val() === 'True'; if (!itemIsPartOfGroup) return true; // Continue to next iteration @@ -173,7 +203,9 @@ function validateParentChildAmounts(correlationId) { let itemGroupKey = itemIsChild ? itemParentRefNo : itemSubmissionCode; if (itemGroupKey === groupKey) { - let amountInput = $(`input[name="ApplicationPaymentRequestForm[${itemIndex}].Amount"]`); + let amountInput = $( + `input[name="ApplicationPaymentRequestForm[${itemIndex}].Amount"]` + ); let amount = parseFloat(amountInput.val().replace(/,/g, '')) || 0; groupTotal += amount; groupMembers.push(itemCorrelationId); @@ -184,16 +216,20 @@ function validateParentChildAmounts(correlationId) { let hasError = groupTotal > maximumAllowed; // Show/hide errors for ALL members of the group - groupMembers.forEach(function(memberId) { + groupMembers.forEach(function (memberId) { let errorDiv = $(`#column_${memberId}_parent_child_error`); let errorMessage = $(`#parent_child_error_message_${memberId}`); if (hasError) { - let message = `Parent-child total (${formatCurrency(groupTotal)}) exceeds maximum allowed by parent (${formatCurrency(maximumAllowed)})`; + let message = `Parent-child total (${formatCurrency( + groupTotal + )}) exceeds maximum allowed by parent (${formatCurrency( + maximumAllowed + )})`; errorMessage.text(message); - errorDiv.css("display", "block"); + errorDiv.css('display', 'block'); } else { - errorDiv.css("display", "none"); + errorDiv.css('display', 'none'); } }); } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index 30c5e3081..2fd4a5f98 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -326,6 +326,7 @@ $(function () { getTagsColumn(columnIndex++), getNoteColumn(columnIndex++), getAccountDistributionColumn(columnIndex++), + getFsbNotifiedColumn(columnIndex++), ] return columns.map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })); @@ -686,6 +687,23 @@ $(function () { }; } + function getFsbNotifiedColumn(columnIndex) { + return { + title: l('ApplicationPaymentListTable:FsbApNotified'), + name: 'fsbApNotified', + data: 'fsbApNotified', + className: 'data-table-header', + index: columnIndex, + render: function (data, type, row) { + if (data) { + return data; + } + // Show placeholder for null/empty + return nullPlaceholder; + } + }; + } + function getExpenseApprovalsDetails(expenseApprovals, type) { return expenseApprovals.find(x => x.type == type); } @@ -696,10 +714,6 @@ $(function () { }).toUTC().toLocaleString() : null; } - /* the resizer needs looking at again after ux2 refactor - window.addEventListener('resize', setTableHeighDynamic('PaymentRequestListTable')); - */ - $('#search').on('input', function () { let table = $('#PaymentRequestListTable').DataTable(); table.search($(this).val()).draw(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js index ad6747ee2..38e077e83 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js @@ -53,37 +53,53 @@ $(function () { } }); + // Helper functions to reduce nesting depth + function hasMatchingId(tagA, tagB) { + return tagA.id === tagB.id; + } + + function tagExistsInList(tag, tagList) { + return tagList.some(t => hasMatchingId(t, tag)); + } + + function filterCommonTags(prev, next) { + return prev.filter(p => tagExistsInList(p, next)); + } + + function getUncommonTags(tagList) { + return tagList.filter(tag => !tagExistsInList(tag, commonTags)); + } + + function sortByName(a, b) { + return a.name.localeCompare(b.name); + } let groupedValues = Object.values(groupedTags); if (groupedValues.length > 0) { - commonTags = groupedValues.reduce(function (prev, next) { - return prev.filter(p => next.some(n => n.id === p.id)); - }); + commonTags = groupedValues.reduce(filterCommonTags); } - let alltags = Object.entries(groupedTags).map(([paymentId, tagList]) => { - let uncommon = tagList.filter(tag => !commonTags.some(ct => ct.id === tag.id)); + + let allTagEntries = Object.entries(groupedTags).map(([paymentId, tagList]) => { + let uncommon = getUncommonTags(tagList); return { paymentRequestId : paymentId, - commonTags: [...commonTags].sort((a, b) => a.name.localeCompare(b.name)), - uncommonTags: uncommon.sort((a, b) => a.name.localeCompare(b.name)) + commonTags: [...commonTags].sort(sortByName), + uncommonTags: uncommon.sort(sortByName) }; }); - $('#TagsJson').val(JSON.stringify(alltags)); + $('#TagsJson').val(JSON.stringify(allTagEntries)); let tagInputArray = []; Object.entries(groupedTags).forEach(function ([paymentId, tagList]) { - let uncommon = tagList.filter(tag => !commonTags.some(ct => ct.id === tag.id)); + let uncommon = getUncommonTags(tagList); uncommonTags = uncommonTags.concat(uncommon); - - }); - if (uncommonTags.length > 0) { tagInputArray.unshift({ tagId: '00000000-0000-0000-0000-000000000000', @@ -139,7 +155,7 @@ $(function () { $('*[data-selector="batch-payment-table-actions"]').prop('disabled', true); $('*[data-selector="batch-payment-table-actions"]').addClass('action-bar-btn-unavailable'); $('.action-bar').addClass('disabled'); - $('#tagPayment').prop('disabled', true); + $('#tagPayment').prop('disabled', true); } else { $('*[data-selector="batch-payment-table-actions"]').prop('disabled', false); @@ -147,11 +163,10 @@ $(function () { $('.action-bar').addClass('active'); $('#tagPayment').removeClass('disabled'); $('#tagPayment').prop('disabled', false); - } } - $('#tagPayment').click(function () { + $('#tagPayment').on('click', function () { // Store payment IDs in distributed cache to avoid URL length limits unity.payments.paymentRequests.paymentBulkActions .storePaymentIds({ paymentRequestIds: selectedPaymentIds }) @@ -176,6 +191,5 @@ $(function () { selectedPaymentIds = []; PubSub.publish("refresh_payment_list"); }); - }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index 14f02cbe3..3cceaffd6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -477,10 +477,6 @@ : '{Not Available}'; } - /* the resizer needs looking at again after ux2 refactor - window.addEventListener('resize', setTableHeighDynamic('PaymentRequestListTable')); - */ - PubSub.subscribe('refresh_application_list', (msg, data) => { dataTable.ajax.reload(null, false); PubSub.publish('clear_payment_application'); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs index 8cc80fcf6..162fa4f7d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs @@ -15,8 +15,6 @@ namespace Unity.Payments.PaymentRequests; public class PaymentRequestAppService_Tests : PaymentsApplicationTestBase { - private readonly ICurrentUser _currentUser; - private readonly IExternalUserLookupServiceProvider _externalUserLookupServiceProvider; private readonly IPaymentRequestAppService _paymentRequestAppService; private readonly IPaymentRequestRepository _paymentRequestRepository; private readonly ISupplierRepository _supplierRepository; @@ -24,8 +22,6 @@ public class PaymentRequestAppService_Tests : PaymentsApplicationTestBase public PaymentRequestAppService_Tests() { - _currentUser = ServiceProvider.GetRequiredService(); - _externalUserLookupServiceProvider = GetRequiredService(); _paymentRequestAppService = GetRequiredService(); _paymentRequestRepository = GetRequiredService(); _supplierRepository = GetRequiredService(); @@ -42,8 +38,7 @@ public async Task CreateAsync_CreatesPaymentRequest() var newSupplier = new Supplier(Guid.NewGuid(), "Supplier", "123", - Guid.NewGuid(), - "Test", + new Modules.Shared.Correlation.Correlation(Guid.NewGuid(), "Test"), new MailingAddress( "Address1", "City", @@ -64,7 +59,10 @@ public async Task CreateAsync_CreatesPaymentRequest() _ = await _supplierRepository.InsertAsync(newSupplier, true); - List paymentRequests = new List { new CreatePaymentRequestDto() { + List paymentRequests = + [ + new() + { Amount = 50, InvoiceNumber ="Test", ContractNumber ="", @@ -73,7 +71,8 @@ public async Task CreateAsync_CreatesPaymentRequest() PayeeName= "", SiteId= siteId, SupplierNumber = "", - } }; + } + ]; // Act var insertedPaymentRequest = await _paymentRequestAppService .CreateAsync(paymentRequests); @@ -89,21 +88,23 @@ public async Task GetListAsync_ReturnsPaymentsList() { // Arrange using var uow = _unitOfWorkManager.Begin(); - var supplier = new Supplier(Guid.NewGuid(), "supp", "123", Guid.NewGuid(), "A"); + var supplier = new Supplier(Guid.NewGuid(), "supp", "123", new Modules.Shared.Correlation.Correlation(Guid.NewGuid(), "A")); supplier.AddSite(new Site(Guid.NewGuid(), "123", PaymentGroup.EFT)); var addedSupplier = await _supplierRepository.InsertAsync(supplier); - CreatePaymentRequestDto paymentRequestDto = new CreatePaymentRequestDto(); - paymentRequestDto.InvoiceNumber = ""; - paymentRequestDto.Amount = 100; - paymentRequestDto.PayeeName = "Test"; - paymentRequestDto.ContractNumber = "0000000000"; - paymentRequestDto.SupplierNumber = ""; - paymentRequestDto.SiteId = addedSupplier.Sites[0].Id; - paymentRequestDto.CorrelationId = Guid.NewGuid(); - paymentRequestDto.CorrelationProvider = ""; - paymentRequestDto.ReferenceNumber = "UP-XXXX-000000"; - paymentRequestDto.BatchName = "UNITY_BATCH_1"; - paymentRequestDto.BatchNumber = 1; + CreatePaymentRequestDto paymentRequestDto = new() + { + InvoiceNumber = "", + Amount = 100, + PayeeName = "Test", + ContractNumber = "0000000000", + SupplierNumber = "", + SiteId = addedSupplier.Sites[0].Id, + CorrelationId = Guid.NewGuid(), + CorrelationProvider = "", + ReferenceNumber = "UP-XXXX-000000", + BatchName = "UNITY_BATCH_1", + BatchNumber = 1 + }; _ = await _paymentRequestRepository.InsertAsync(new PaymentRequest(Guid.NewGuid(), paymentRequestDto), true); // Act @@ -122,10 +123,10 @@ public async Task GetListAsync_ReturnsPagedPaymentsList() { // Arrange using var uow = _unitOfWorkManager.Begin(); - var supplier = new Supplier(Guid.NewGuid(), "supp", "123", Guid.NewGuid(), "A"); + var supplier = new Supplier(Guid.NewGuid(), "supp", "123", new Modules.Shared.Correlation.Correlation(Guid.NewGuid(), "A")); supplier.AddSite(new Site(Guid.NewGuid(), "123", PaymentGroup.EFT)); var addedSupplier = await _supplierRepository.InsertAsync(supplier); - CreatePaymentRequestDto paymentRequestDto = new CreatePaymentRequestDto + CreatePaymentRequestDto paymentRequestDto = new() { InvoiceNumber = "INV-001", Amount = 100, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/Suppliers/Supplier_ValueObject_Refactoring_Integration_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/Suppliers/Supplier_ValueObject_Refactoring_Integration_Tests.cs new file mode 100644 index 000000000..e72f80ebd --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/Suppliers/Supplier_ValueObject_Refactoring_Integration_Tests.cs @@ -0,0 +1,384 @@ +using System; +using Shouldly; +using Unity.Modules.Shared.Correlation; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Domain.Suppliers.ValueObjects; +using Xunit; +using System.ComponentModel; + +namespace Unity.Payments.Suppliers; + +/// +/// Integration tests that verify the value object refactoring maintains backward compatibility +/// while providing the benefits of better organization and type safety. +/// +[Category("Integration")] +public class Supplier_ValueObject_Refactoring_Integration_Tests +{ + #region Refactoring Benefits Demonstration + + [Fact] + public void ValueObjectRefactoring_ShouldImproveParameterOrganization() + { + // Before: Constructor with 12+ individual parameters was hard to maintain + // After: Constructor with 6 logical value object groups + + // Arrange - Create test data that would have been 12+ individual parameters + var id = Guid.NewGuid(); + var name = "Refactoring Demo Supplier"; + var number = "RDS001"; + var subcategory = "Company"; + var providerId = "PROV456"; + var businessNumber = "BN987654321"; + var status = "Active"; + var supplierProtected = "No"; + var standardIndustryClassification = "NAICS789"; + var lastUpdatedInCAS = DateTime.UtcNow; + var correlationId = Guid.NewGuid(); + var correlationProvider = "DemoProvider"; + var mailingAddress = "123 Demo Street"; + var city = "Demo City"; + var province = "BC"; + var postalCode = "DEMO123"; + + // Act - Create supplier using new value object approach + var basicInfo = new SupplierBasicInfo(name, number, subcategory); + var providerInfo = new ProviderInfo(providerId, businessNumber); + var supplierStatus = new SupplierStatus(status, supplierProtected, standardIndustryClassification); + var casMetadata = new CasMetadata(lastUpdatedInCAS); + var correlation = new Correlation(correlationId, correlationProvider); + var mailingAddressVO = new MailingAddress(mailingAddress, city, province, postalCode); + + var supplier = new Supplier(id, basicInfo, correlation, providerInfo, supplierStatus, casMetadata, mailingAddressVO); + + // Assert - Verify all data is correctly set + supplier.Id.ShouldBe(id); + supplier.Name.ShouldBe(name); + supplier.Number.ShouldBe(number); + supplier.Subcategory.ShouldBe(subcategory); + supplier.ProviderId.ShouldBe(providerId); + supplier.BusinessNumber.ShouldBe(businessNumber); + supplier.Status.ShouldBe(status); + supplier.SupplierProtected.ShouldBe(supplierProtected); + supplier.StandardIndustryClassification.ShouldBe(standardIndustryClassification); + supplier.LastUpdatedInCAS.ShouldBe(lastUpdatedInCAS); + supplier.CorrelationId.ShouldBe(correlationId); + supplier.CorrelationProvider.ShouldBe(correlationProvider); + supplier.MailingAddress.ShouldBe(mailingAddress); + supplier.City.ShouldBe(city); + supplier.Province.ShouldBe(province); + supplier.PostalCode.ShouldBe(postalCode); + + // Benefits achieved: + // 1. Related parameters grouped into logical units + // 2. Type safety - can't accidentally swap similar parameters + // 3. Easier to extend - add fields to value objects without changing method signatures + // 4. Better maintainability - clear separation of concerns + // 5. Immutable value objects provide additional safety + } + + [Fact] + public void ValueObjectRefactoring_ShouldMaintainBackwardCompatibility() + { + // This test ensures the refactoring doesn't break existing functionality + + // Arrange - Use both old and new approaches with same data + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var testData = new + { + Name = "Compatibility Test", + Number = "COMPAT001", + Subcategory = "Individual", + ProviderId = "COMP_PROV", + BusinessNumber = "COMP_BN123", + Status = "Active", + SupplierProtected = "No", + StandardIndustryClassification = "COMP_NAICS", + LastUpdatedInCAS = DateTime.UtcNow, + CorrelationId = Guid.NewGuid(), + CorrelationProvider = "CompatTest", + MailingAddress = "456 Compat Ave", + City = "Compat City", + Province = "AB", + PostalCode = "COMP456" + }; + + // Act - Create using legacy constructor + var legacySupplier = new Supplier( + id1, + testData.Name, + testData.Number, + new Correlation(testData.CorrelationId, testData.CorrelationProvider), + new MailingAddress(testData.MailingAddress, testData.City, testData.Province, testData.PostalCode)); + + // Manually set properties that weren't in legacy constructor + legacySupplier.Subcategory = testData.Subcategory; + legacySupplier.ProviderId = testData.ProviderId; + legacySupplier.BusinessNumber = testData.BusinessNumber; + legacySupplier.Status = testData.Status; + legacySupplier.SupplierProtected = testData.SupplierProtected; + legacySupplier.StandardIndustryClassification = testData.StandardIndustryClassification; + legacySupplier.LastUpdatedInCAS = testData.LastUpdatedInCAS; + + // Create using new value object constructor + var newSupplier = new Supplier( + id2, + new SupplierBasicInfo(testData.Name, testData.Number, testData.Subcategory), + new Correlation(testData.CorrelationId, testData.CorrelationProvider), + new ProviderInfo(testData.ProviderId, testData.BusinessNumber), + new SupplierStatus(testData.Status, testData.SupplierProtected, testData.StandardIndustryClassification), + new CasMetadata(testData.LastUpdatedInCAS), + new MailingAddress(testData.MailingAddress, testData.City, testData.Province, testData.PostalCode)); + + // Assert - Both approaches should produce identical domain state + legacySupplier.Name.ShouldBe(newSupplier.Name); + legacySupplier.Number.ShouldBe(newSupplier.Number); + legacySupplier.Subcategory.ShouldBe(newSupplier.Subcategory); + legacySupplier.ProviderId.ShouldBe(newSupplier.ProviderId); + legacySupplier.BusinessNumber.ShouldBe(newSupplier.BusinessNumber); + legacySupplier.Status.ShouldBe(newSupplier.Status); + legacySupplier.SupplierProtected.ShouldBe(newSupplier.SupplierProtected); + legacySupplier.StandardIndustryClassification.ShouldBe(newSupplier.StandardIndustryClassification); + legacySupplier.LastUpdatedInCAS.ShouldBe(newSupplier.LastUpdatedInCAS); + legacySupplier.CorrelationId.ShouldBe(newSupplier.CorrelationId); + legacySupplier.CorrelationProvider.ShouldBe(newSupplier.CorrelationProvider); + legacySupplier.MailingAddress.ShouldBe(newSupplier.MailingAddress); + legacySupplier.City.ShouldBe(newSupplier.City); + legacySupplier.Province.ShouldBe(newSupplier.Province); + legacySupplier.PostalCode.ShouldBe(newSupplier.PostalCode); + + // Backward compatibility maintained ? + } + + [Fact] + public void ValueObjectRefactoring_ShouldProvideTypeSafety() + { + // This test demonstrates improved type safety with value objects + + // Arrange + var basicInfo = new SupplierBasicInfo("Supplier Name", "SUP001", "Category"); + var providerInfo = new ProviderInfo("PROV123", "BN456789"); + var supplierStatus = new SupplierStatus("Active", "No", "NAICS123"); + + // Act & Assert - Value objects prevent parameter confusion + // Before: Easy to accidentally swap parameters of same type (string) + // new Supplier(id, "PROV123", "SUP001", "Supplier Name", ...) // Accidentally swapped! + + // After: Impossible to swap because each value object has distinct type + var supplier = new Supplier( + Guid.NewGuid(), + basicInfo, // Can't pass providerInfo here - compiler error + new Correlation(Guid.NewGuid(), "Provider"), + providerInfo, // Can't pass basicInfo here - compiler error + supplierStatus, + new CasMetadata(DateTime.UtcNow), + new MailingAddress("Address", "City", "Province", "PostalCode")); + + // Value objects provide compile-time safety ? + supplier.ShouldNotBeNull(); + basicInfo.Name.ShouldBe("Supplier Name"); + providerInfo.ProviderId.ShouldBe("PROV123"); + supplierStatus.Status.ShouldBe("Active"); + } + + [Fact] + public void ValueObjectRefactoring_ShouldSupportUpdateOperations() + { + // This test verifies that the new update methods work correctly with value objects + + // Arrange + var supplier = new Supplier( + Guid.NewGuid(), + new SupplierBasicInfo("Original Name", "ORIG001", "Original Category"), + new Correlation(Guid.NewGuid(), "Original")); + + // Act - Update using value object methods + supplier.UpdateBasicInfo(new SupplierBasicInfo("Updated Name", "UPD001", "Updated Category")); + supplier.UpdateProviderInfo(new ProviderInfo("NEW_PROV", "NEW_BN")); + supplier.UpdateStatus(new SupplierStatus("Inactive", "Yes", "NEW_NAICS")); + supplier.UpdateCasMetadata(new CasMetadata(DateTime.UtcNow)); + + // Assert - Verify updates were applied correctly + supplier.Name.ShouldBe("Updated Name"); + supplier.Number.ShouldBe("UPD001"); + supplier.Subcategory.ShouldBe("Updated Category"); + supplier.ProviderId.ShouldBe("NEW_PROV"); + supplier.BusinessNumber.ShouldBe("NEW_BN"); + supplier.Status.ShouldBe("Inactive"); + supplier.SupplierProtected.ShouldBe("Yes"); + supplier.StandardIndustryClassification.ShouldBe("NEW_NAICS"); + supplier.LastUpdatedInCAS.ShouldNotBeNull(); + + // Update methods provide clean, grouped operations ? + } + + [Fact] + public void ValueObjectRefactoring_ShouldHandlePartialData() + { + // This test verifies that optional value objects work correctly + + // Arrange & Act - Create supplier with only required data + var supplier = new Supplier( + Guid.NewGuid(), + new SupplierBasicInfo("Minimal Supplier", "MIN001"), + new Correlation(Guid.NewGuid(), "MinimalProvider")); + // Optional parameters: providerInfo, supplierStatus, casMetadata, mailingAddress all default to null + + // Assert - Required data set, optional data has appropriate defaults + supplier.Name.ShouldBe("Minimal Supplier"); + supplier.Number.ShouldBe("MIN001"); + supplier.Subcategory.ShouldBeNull(); // Optional in SupplierBasicInfo + + // Properties from optional value objects should be string.Empty when value objects not provided + // The null-coalescing operator preserves the existing property default values + supplier.ProviderId.ShouldBe(string.Empty); // Preserves default value + supplier.BusinessNumber.ShouldBe(string.Empty); // Preserves default value + supplier.Status.ShouldBe(string.Empty); // Preserves default value + supplier.SupplierProtected.ShouldBe(string.Empty); // Preserves default value + supplier.StandardIndustryClassification.ShouldBe(string.Empty); // Preserves default value + supplier.LastUpdatedInCAS.ShouldBeNull(); // casMetadata?.LastUpdatedInCAS returns null + supplier.MailingAddress.ShouldBeNull(); // mailingAddress?.AddressLine returns null + supplier.City.ShouldBeNull(); + supplier.Province.ShouldBeNull(); + supplier.PostalCode.ShouldBeNull(); + + // Partial data creation works correctly ? + } + + #endregion + + #region Value Object Record Benefits + + [Fact] + public void ValueObjectRecords_ShouldProvideValueEquality() + { + // This test demonstrates the benefits of using records for value objects + + // Arrange & Act + var basicInfo1 = new SupplierBasicInfo("Test Supplier", "TEST001", "Category"); + var basicInfo2 = new SupplierBasicInfo("Test Supplier", "TEST001", "Category"); + var basicInfo3 = new SupplierBasicInfo("Different Supplier", "DIFF001", "Category"); + + // Assert - Records provide automatic value equality + basicInfo1.ShouldBe(basicInfo2); // Same values = equal + basicInfo1.ShouldNotBe(basicInfo3); // Different values = not equal + (basicInfo1 == basicInfo2).ShouldBeTrue(); + (basicInfo1 == basicInfo3).ShouldBeFalse(); + basicInfo1.GetHashCode().ShouldBe(basicInfo2.GetHashCode()); // Same hash for equal values + + // Records provide immutability and value equality out of the box ? + } + + [Fact] + public void ValueObjectRecords_ShouldSupportWithExpressions() + { + // This test demonstrates record 'with' expressions for modifications + + // Arrange + var originalBasicInfo = new SupplierBasicInfo("Original Name", "ORIG001", "Original Category"); + + // Act - Use 'with' expressions to create modified copies + var modifiedName = originalBasicInfo with { Name = "Modified Name" }; + var modifiedNumber = originalBasicInfo with { Number = "MOD001" }; + + // Assert - Original unchanged, new instances created with modifications + originalBasicInfo.Name.ShouldBe("Original Name"); + originalBasicInfo.Number.ShouldBe("ORIG001"); + originalBasicInfo.Subcategory.ShouldBe("Original Category"); + + modifiedName.Name.ShouldBe("Modified Name"); + modifiedName.Number.ShouldBe("ORIG001"); // Unchanged + modifiedName.Subcategory.ShouldBe("Original Category"); // Unchanged + + modifiedNumber.Name.ShouldBe("Original Name"); // Unchanged + modifiedNumber.Number.ShouldBe("MOD001"); + modifiedNumber.Subcategory.ShouldBe("Original Category"); // Unchanged + + // Records provide convenient immutable modification patterns ? + } + + #endregion + + #region Integration with CAS Service Scenario + + [Fact] + public void ValueObjectRefactoring_ShouldWorkWithCasIntegration() + { + // This test simulates how the value objects work in a CAS integration scenario + + // Arrange - Simulate CAS response data + var casSupplierData = new + { + suppliername = "CAS Test Supplier", + suppliernumber = "CAS001", + subcategory = "Individual", + providerid = "CAS_PROV_123", + businessnumber = "CAS_BN_987654321", + status = "ACTIVE", + supplierprotected = "N", + standardindustryclassification = "CAS_NAICS_456", + lastupdated = DateTime.UtcNow.AddDays(-1), + correlationid = Guid.NewGuid(), + correlationprovider = "CAS", + mailingaddress = "789 CAS Boulevard", + city = "CAS City", + province = "BC", + postalcode = "CAS123" + }; + + // Act - Transform CAS data into value objects (as SupplierService would do) + var basicInfo = new SupplierBasicInfo( + casSupplierData.suppliername, + casSupplierData.suppliernumber, + casSupplierData.subcategory); + + var providerInfo = new ProviderInfo( + casSupplierData.providerid, + casSupplierData.businessnumber); + + var supplierStatus = new SupplierStatus( + casSupplierData.status, + casSupplierData.supplierprotected, + casSupplierData.standardindustryclassification); + + var casMetadata = new CasMetadata(casSupplierData.lastupdated); + + var correlation = new Correlation( + casSupplierData.correlationid, + casSupplierData.correlationprovider); + + var mailingAddress = new MailingAddress( + casSupplierData.mailingaddress, + casSupplierData.city, + casSupplierData.province, + casSupplierData.postalcode); + + // Create supplier from CAS data using value objects + var supplier = new Supplier( + Guid.NewGuid(), + basicInfo, + correlation, + providerInfo, + supplierStatus, + casMetadata, + mailingAddress); + + // Assert - Verify CAS data correctly mapped to domain object + supplier.Name.ShouldBe(casSupplierData.suppliername); + supplier.Number.ShouldBe(casSupplierData.suppliernumber); + supplier.Subcategory.ShouldBe(casSupplierData.subcategory); + supplier.ProviderId.ShouldBe(casSupplierData.providerid); + supplier.BusinessNumber.ShouldBe(casSupplierData.businessnumber); + supplier.Status.ShouldBe(casSupplierData.status); + supplier.SupplierProtected.ShouldBe(casSupplierData.supplierprotected); + supplier.StandardIndustryClassification.ShouldBe(casSupplierData.standardindustryclassification); + supplier.LastUpdatedInCAS.ShouldBe(casSupplierData.lastupdated); + supplier.CorrelationId.ShouldBe(casSupplierData.correlationid); + supplier.CorrelationProvider.ShouldBe(casSupplierData.correlationprovider); + + // Value objects work seamlessly with external integrations ? + } + + #endregion +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/Suppliers/Supplier_ValueObjects_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/Suppliers/Supplier_ValueObjects_Tests.cs new file mode 100644 index 000000000..473131c0f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/Suppliers/Supplier_ValueObjects_Tests.cs @@ -0,0 +1,374 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Unity.Modules.Shared.Correlation; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Domain.Suppliers.ValueObjects; +using Xunit; +using System.ComponentModel; + +namespace Unity.Payments.Domain.Suppliers; + +[Category("Domain")] +public class Supplier_ValueObjects_Tests : PaymentsApplicationTestBase +{ + #region Value Object Creation Tests + + [Fact] + public void SupplierBasicInfo_Create_ShouldSetPropertiesCorrectly() + { + // Arrange + var name = "Test Supplier"; + var number = "SUP001"; + var subcategory = "Category A"; + + // Act + var basicInfo = new SupplierBasicInfo(name, number, subcategory); + + // Assert + basicInfo.Name.ShouldBe(name); + basicInfo.Number.ShouldBe(number); + basicInfo.Subcategory.ShouldBe(subcategory); + } + + [Fact] + public void ProviderInfo_Create_ShouldSetPropertiesCorrectly() + { + // Arrange + var providerId = "PROV123"; + var businessNumber = "BN123456789"; + + // Act + var providerInfo = new ProviderInfo(providerId, businessNumber); + + // Assert + providerInfo.ProviderId.ShouldBe(providerId); + providerInfo.BusinessNumber.ShouldBe(businessNumber); + } + + [Fact] + public void SupplierStatus_Create_ShouldSetPropertiesCorrectly() + { + // Arrange + var status = "Active"; + var supplierProtected = "No"; + var standardIndustryClassification = "NAICS123"; + + // Act + var supplierStatus = new SupplierStatus(status, supplierProtected, standardIndustryClassification); + + // Assert + supplierStatus.Status.ShouldBe(status); + supplierStatus.SupplierProtected.ShouldBe(supplierProtected); + supplierStatus.StandardIndustryClassification.ShouldBe(standardIndustryClassification); + } + + [Fact] + public void CasMetadata_Create_ShouldSetPropertiesCorrectly() + { + // Arrange + var lastUpdated = DateTime.UtcNow; + + // Act + var casMetadata = new CasMetadata(lastUpdated); + + // Assert + casMetadata.LastUpdatedInCAS.ShouldBe(lastUpdated); + } + + #endregion + + #region Value Object Equality Tests (Records) + + [Fact] + public void SupplierBasicInfo_EqualRecords_ShouldBeEqual() + { + // Arrange + var basicInfo1 = new SupplierBasicInfo("Test", "SUP001", "Category"); + var basicInfo2 = new SupplierBasicInfo("Test", "SUP001", "Category"); + + // Assert + basicInfo1.ShouldBe(basicInfo2); + (basicInfo1 == basicInfo2).ShouldBeTrue(); + basicInfo1.GetHashCode().ShouldBe(basicInfo2.GetHashCode()); + } + + [Fact] + public void ProviderInfo_DifferentRecords_ShouldNotBeEqual() + { + // Arrange + var providerInfo1 = new ProviderInfo("PROV123", "BN111"); + var providerInfo2 = new ProviderInfo("PROV123", "BN222"); + + // Assert + providerInfo1.ShouldNotBe(providerInfo2); + (providerInfo1 == providerInfo2).ShouldBeFalse(); + } + + #endregion + + #region Supplier Constructor with Value Objects Tests + + [Fact] + public void Supplier_CreateWithValueObjects_ShouldSetAllPropertiesCorrectly() + { + // Arrange + var id = Guid.NewGuid(); + var basicInfo = new SupplierBasicInfo("Test Supplier", "SUP001", "Category A"); + var providerInfo = new ProviderInfo("PROV123", "BN123456789"); + var supplierStatus = new SupplierStatus("Active", "No", "NAICS123"); + var casMetadata = new CasMetadata(DateTime.UtcNow); + var correlation = new Correlation(Guid.NewGuid(), "CAS"); + var mailingAddress = new MailingAddress("123 Main St", "Victoria", "BC", "V8V1A1"); + + // Act + var supplier = new Supplier(id, basicInfo, correlation, providerInfo, supplierStatus, casMetadata, mailingAddress); + + // Assert + supplier.Id.ShouldBe(id); + supplier.Name.ShouldBe(basicInfo.Name); + supplier.Number.ShouldBe(basicInfo.Number); + supplier.Subcategory.ShouldBe(basicInfo.Subcategory); + supplier.ProviderId.ShouldBe(providerInfo.ProviderId); + supplier.BusinessNumber.ShouldBe(providerInfo.BusinessNumber); + supplier.Status.ShouldBe(supplierStatus.Status); + supplier.SupplierProtected.ShouldBe(supplierStatus.SupplierProtected); + supplier.StandardIndustryClassification.ShouldBe(supplierStatus.StandardIndustryClassification); + supplier.LastUpdatedInCAS.ShouldBe(casMetadata.LastUpdatedInCAS); + supplier.CorrelationId.ShouldBe(correlation.CorrelationId); + supplier.CorrelationProvider.ShouldBe(correlation.CorrelationProvider); + supplier.MailingAddress.ShouldBe(mailingAddress.AddressLine); + supplier.City.ShouldBe(mailingAddress.City); + supplier.Province.ShouldBe(mailingAddress.Province); + supplier.PostalCode.ShouldBe(mailingAddress.PostalCode); + supplier.Sites.ShouldNotBeNull(); + supplier.Sites.Count.ShouldBe(0); + } + + [Fact] + public void Supplier_CreateWithNullOptionalValueObjects_ShouldHandleNullsCorrectly() + { + // Arrange + var id = Guid.NewGuid(); + var basicInfo = new SupplierBasicInfo("Test Supplier", "SUP001"); + var correlation = new Correlation(Guid.NewGuid(), "CAS"); + + // Act + var supplier = new Supplier(id, basicInfo, correlation); + + // Assert + supplier.Id.ShouldBe(id); + supplier.Name.ShouldBe("Test Supplier"); + supplier.Number.ShouldBe("SUP001"); + supplier.Subcategory.ShouldBeNull(); + supplier.ProviderId.ShouldBe(string.Empty); // Preserves default value due to null-coalescing operator + supplier.BusinessNumber.ShouldBe(string.Empty); // Preserves default value due to null-coalescing operator + supplier.Status.ShouldBe(string.Empty); // Preserves default value due to null-coalescing operator + supplier.SupplierProtected.ShouldBe(string.Empty); // Preserves default value due to null-coalescing operator + supplier.StandardIndustryClassification.ShouldBe(string.Empty); // Preserves default value due to null-coalescing operator + supplier.LastUpdatedInCAS.ShouldBeNull(); // DateTime? remains null + supplier.MailingAddress.ShouldBeNull(); + supplier.City.ShouldBeNull(); + supplier.Province.ShouldBeNull(); + supplier.PostalCode.ShouldBeNull(); + } + + [Fact] + public void Supplier_CreateWithPartialValueObjects_ShouldSetOnlyProvidedValues() + { + // Arrange + var id = Guid.NewGuid(); + var basicInfo = new SupplierBasicInfo("Test Supplier", "SUP001", "Category"); + var providerInfo = new ProviderInfo("PROV123", null); // Only ProviderId, no BusinessNumber + var correlation = new Correlation(Guid.NewGuid(), "CAS"); + + // Act + var supplier = new Supplier(id, basicInfo, correlation, providerInfo: providerInfo); + + // Assert + supplier.Name.ShouldBe("Test Supplier"); + supplier.Number.ShouldBe("SUP001"); + supplier.Subcategory.ShouldBe("Category"); + supplier.ProviderId.ShouldBe("PROV123"); + supplier.BusinessNumber.ShouldBe(string.Empty); // Null-coalescing operator preserves default when null provided + supplier.Status.ShouldBe(string.Empty); // Preserves default value when SupplierStatus not provided + supplier.LastUpdatedInCAS.ShouldBeNull(); // Not provided + } + + #endregion + + #region Update Methods Tests + + [Fact] + public void Supplier_UpdateBasicInfo_ShouldUpdateCorrectProperties() + { + // Arrange + var supplier = CreateTestSupplier(); + var newBasicInfo = new SupplierBasicInfo("Updated Name", "SUP999", "Updated Category"); + + // Act + supplier.UpdateBasicInfo(newBasicInfo); + + // Assert + supplier.Name.ShouldBe("Updated Name"); + supplier.Number.ShouldBe("SUP999"); + supplier.Subcategory.ShouldBe("Updated Category"); + // Other properties should remain unchanged + supplier.ProviderId.ShouldBe("PROV123"); + supplier.Status.ShouldBe("Active"); + } + + [Fact] + public void Supplier_UpdateProviderInfo_ShouldUpdateCorrectProperties() + { + // Arrange + var supplier = CreateTestSupplier(); + var newProviderInfo = new ProviderInfo("NEWPROV456", "NEWBN987654321"); + + // Act + supplier.UpdateProviderInfo(newProviderInfo); + + // Assert + supplier.ProviderId.ShouldBe("NEWPROV456"); + supplier.BusinessNumber.ShouldBe("NEWBN987654321"); + // Other properties should remain unchanged + supplier.Name.ShouldBe("Test Supplier"); + supplier.Status.ShouldBe("Active"); + } + + [Fact] + public void Supplier_UpdateStatus_ShouldUpdateCorrectProperties() + { + // Arrange + var supplier = CreateTestSupplier(); + var newStatus = new SupplierStatus("Inactive", "Yes", "NEWNAICS456"); + + // Act + supplier.UpdateStatus(newStatus); + + // Assert + supplier.Status.ShouldBe("Inactive"); + supplier.SupplierProtected.ShouldBe("Yes"); + supplier.StandardIndustryClassification.ShouldBe("NEWNAICS456"); + // Other properties should remain unchanged + supplier.Name.ShouldBe("Test Supplier"); + supplier.ProviderId.ShouldBe("PROV123"); + } + + [Fact] + public void Supplier_UpdateCasMetadata_ShouldUpdateCorrectProperties() + { + // Arrange + var supplier = CreateTestSupplier(); + var newDate = DateTime.UtcNow.AddDays(1); + var newCasMetadata = new CasMetadata(newDate); + + // Act + supplier.UpdateCasMetadata(newCasMetadata); + + // Assert + supplier.LastUpdatedInCAS.ShouldBe(newDate); + // Other properties should remain unchanged + supplier.Name.ShouldBe("Test Supplier"); + supplier.Status.ShouldBe("Active"); + } + + #endregion + + #region Backward Compatibility Tests + + [Fact] + public void Supplier_LegacyConstructor_ShouldStillWork() + { + // Arrange + var id = Guid.NewGuid(); + var name = "Legacy Supplier"; + var number = "LEG001"; + var correlation = new Correlation(Guid.NewGuid(), "Legacy"); + var mailingAddress = new MailingAddress("Legacy Address", "Legacy City", "BC", "L3G4CY"); + + // Act + var supplier = new Supplier(id, name, number, correlation, mailingAddress); + + // Assert + supplier.Id.ShouldBe(id); + supplier.Name.ShouldBe(name); + supplier.Number.ShouldBe(number); + supplier.CorrelationId.ShouldBe(correlation.CorrelationId); + supplier.CorrelationProvider.ShouldBe(correlation.CorrelationProvider); + supplier.MailingAddress.ShouldBe(mailingAddress.AddressLine); + supplier.City.ShouldBe(mailingAddress.City); + supplier.Province.ShouldBe(mailingAddress.Province); + supplier.PostalCode.ShouldBe(mailingAddress.PostalCode); + + // Properties not set in legacy constructor should have default values (string.Empty, not null) + supplier.Subcategory.ShouldBe(string.Empty); + supplier.ProviderId.ShouldBe(string.Empty); + supplier.BusinessNumber.ShouldBe(string.Empty); + supplier.Status.ShouldBe(string.Empty); + supplier.SupplierProtected.ShouldBe(string.Empty); + supplier.StandardIndustryClassification.ShouldBe(string.Empty); + supplier.LastUpdatedInCAS.ShouldBeNull(); // This one is nullable DateTime, so remains null + } + + #endregion + + #region Edge Case Tests + + [Fact] + public void ValueObjects_WithNullValues_ShouldHandleGracefully() + { + // Arrange & Act + var basicInfo = new SupplierBasicInfo(null, null, null); + var providerInfo = new ProviderInfo(null, null); + var supplierStatus = new SupplierStatus(null, null, null); + var casMetadata = new CasMetadata(null); + + // Assert + basicInfo.Name.ShouldBeNull(); + basicInfo.Number.ShouldBeNull(); + basicInfo.Subcategory.ShouldBeNull(); + providerInfo.ProviderId.ShouldBeNull(); + providerInfo.BusinessNumber.ShouldBeNull(); + supplierStatus.Status.ShouldBeNull(); + supplierStatus.SupplierProtected.ShouldBeNull(); + supplierStatus.StandardIndustryClassification.ShouldBeNull(); + casMetadata.LastUpdatedInCAS.ShouldBeNull(); + } + + [Fact] + public void ValueObjects_WithDefaultParameters_ShouldWork() + { + // Test that default parameters work correctly + var basicInfo1 = new SupplierBasicInfo("Name", "Number"); + var basicInfo2 = new SupplierBasicInfo("Name", "Number", default); + + basicInfo1.ShouldBe(basicInfo2); + basicInfo1.Subcategory.ShouldBeNull(); + + var providerInfo1 = new ProviderInfo("Provider"); + var providerInfo2 = new ProviderInfo("Provider", default); + + providerInfo1.ShouldBe(providerInfo2); + providerInfo1.BusinessNumber.ShouldBeNull(); + } + + #endregion + + #region Helper Methods + + private static Supplier CreateTestSupplier() + { + var id = Guid.NewGuid(); + var basicInfo = new SupplierBasicInfo("Test Supplier", "SUP001", "Category A"); + var providerInfo = new ProviderInfo("PROV123", "BN123456789"); + var supplierStatus = new SupplierStatus("Active", "No", "NAICS123"); + var casMetadata = new CasMetadata(DateTime.UtcNow); + var correlation = new Correlation(Guid.NewGuid(), "Test"); + var mailingAddress = new MailingAddress("123 Test St", "Test City", "BC", "T3ST1NG"); + + return new Supplier(id, basicInfo, correlation, providerInfo, supplierStatus, casMetadata, mailingAddress); + } + + #endregion +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Security/FakeExternalUserLookupServiceProvider.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Security/FakeExternalUserLookupServiceProvider.cs index faa9416b4..eb47d77fa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Security/FakeExternalUserLookupServiceProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Security/FakeExternalUserLookupServiceProvider.cs @@ -13,11 +13,11 @@ public class FakeExternalUserLookupServiceProvider : IExternalUserLookupServiceP { return Task.FromResult(id switch { - var userId when userId == PaymentsTestData.UserDataMocks.User1.Id => PaymentsTestData.UserDataMocks.User1, - var userId when userId == PaymentsTestData.UserDataMocks.User2.Id => PaymentsTestData.UserDataMocks.User2, - var userId when userId == PaymentsTestData.UserDataMocks.User3.Id => PaymentsTestData.UserDataMocks.User3, - var userId when userId == PaymentsTestData.UserDataMocks.User4.Id => PaymentsTestData.UserDataMocks.User4, - var userId when userId == PaymentsTestData.UserDataMocks.User5.Id => PaymentsTestData.UserDataMocks.User5, + _ when id == PaymentsTestData.UserDataMocks.User1.Id => PaymentsTestData.UserDataMocks.User1, + _ when id == PaymentsTestData.UserDataMocks.User2.Id => PaymentsTestData.UserDataMocks.User2, + _ when id == PaymentsTestData.UserDataMocks.User3.Id => PaymentsTestData.UserDataMocks.User3, + _ when id == PaymentsTestData.UserDataMocks.User4.Id => PaymentsTestData.UserDataMocks.User4, + _ when id == PaymentsTestData.UserDataMocks.User5.Id => PaymentsTestData.UserDataMocks.User5, _ => new UserData(id, id.ToString()) }); } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs index 51e93ceba..6626f00ca 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs @@ -68,12 +68,12 @@ public interface IReportMappingService public Task GetFieldsMetadataAsync(Guid correlationId, string correlationProvider); /// - /// Deletes a report mapping for a specific correlation and optionally deletes the associated database view. + /// Deletes a report mapping for a specific correlation and deletes the associated database view. /// /// The unique identifier of the correlated entity whose mapping should be deleted. /// The provider type identifier (e.g., "worksheet", "scoresheet", "chefs"). - /// Whether to also delete the associated database view if it exists. Defaults to true. - public Task DeleteAsync(Guid correlationId, string correlationProvider, bool deleteView = true); + /// A DeleteResult indicating what was successfully deleted. + public Task DeleteAsync(Guid correlationId, string correlationProvider); /// /// Checks if a view name is available for use in the database by verifying it doesn't already exist. @@ -186,4 +186,31 @@ public class ViewDataResult /// public string[] ColumnNames { get; set; } = []; } + + /// + /// Represents the result of a delete operation indicating what was successfully removed. + /// Provides detailed information about configuration and view deletion for accurate user feedback. + /// + public class DeleteResult + { + /// + /// Gets or sets whether the report mapping configuration was successfully deleted. + /// + public bool ConfigurationDeleted { get; set; } + + /// + /// Gets or sets whether a database view was successfully deleted. + /// + public bool ViewDeleted { get; set; } + + /// + /// Gets or sets the name of the view that was deleted, if any. + /// + public string? DeletedViewName { get; set; } + + /// + /// Gets or sets any warning or informational messages about the deletion process. + /// + public string? Message { get; set; } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs index 5b9565642..7524e0cf4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs @@ -512,13 +512,12 @@ public async Task GetViewStatusByCorrlationAsync( /// Provides comprehensive cleanup of both the mapping configuration and any generated database objects. /// /// The unique identifier of the correlated entity whose mapping should be deleted. - /// The provider type identifier (e.g., "worksheet", "scoresheet", "chefs"). - /// Whether to also delete the associated database view if it exists. Defaults to true. - /// A task representing the asynchronous delete operation. + /// The provider type identifier (e.g., "worksheet", "scoresheet", "chefs"). + /// A DeleteResult indicating what was successfully deleted. /// Thrown when an unknown or invalid correlation provider is specified. /// Thrown when no mapping exists for the specified correlation. [Authorize(ReportingPermissions.Configuration.Delete)] - public async Task DeleteAsync(Guid correlationId, string correlationProvider, bool deleteView = true) + public async Task DeleteAsync(Guid correlationId, string correlationProvider) { var providerKey = correlationProvider?.ToLowerInvariant() ?? string.Empty; @@ -532,30 +531,39 @@ public async Task DeleteAsync(Guid correlationId, string correlationProvider, bo var reportColumnsMap = await reportColumnsMapRepository.FindByCorrelationAsync(correlationId, correlationProvider) ?? throw new EntityNotFoundException(typeof(ReportColumnsMap), $"CorrelationId: {correlationId}, CorrelationProvider: {correlationProvider}"); - // Delete the associated view if requested and it exists - if (deleteView && !string.IsNullOrWhiteSpace(reportColumnsMap.ViewName)) + var deleteResult = new DeleteResult(); + var viewName = reportColumnsMap.ViewName; + + // Delete the associated view if it exists + if (!string.IsNullOrWhiteSpace(viewName)) { try { - var viewExists = await reportColumnsMapRepository.ViewExistsAsync(reportColumnsMap.ViewName); + var viewExists = await reportColumnsMapRepository.ViewExistsAsync(viewName); if (viewExists) { - await reportColumnsMapRepository.DeleteViewAsync(reportColumnsMap.ViewName); - Logger.LogInformation("Deleted database view: {ViewName}", reportColumnsMap.ViewName); + await reportColumnsMapRepository.DeleteViewAsync(viewName); + deleteResult.ViewDeleted = true; + deleteResult.DeletedViewName = viewName; + Logger.LogInformation("Deleted database view: {ViewName}", viewName); } } catch (Exception ex) { - Logger.LogWarning(ex, "Failed to delete database view: {ViewName}. Continuing with mapping deletion.", reportColumnsMap.ViewName); + Logger.LogWarning(ex, "Failed to delete database view: {ViewName}. Continuing with mapping deletion.", viewName); + deleteResult.Message = $"Warning: Failed to delete database view '{viewName}'. The mapping was still deleted."; // Continue with mapping deletion even if view deletion fails } } // Delete the mapping record await reportColumnsMapRepository.DeleteAsync(reportColumnsMap); + deleteResult.ConfigurationDeleted = true; Logger.LogInformation("Deleted report mapping for CorrelationId: {CorrelationId}, CorrelationProvider: {CorrelationProvider}", correlationId, correlationProvider); + + return deleteResult; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml index bd0da021a..03e3c82fe 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml @@ -222,19 +222,13 @@ Warning: This action cannot be undone.
-

Are you sure you want to delete this reporting configuration?

-
- - -
+

Are you sure you want to delete this reporting configuration?

This will permanently remove:

  • The column mapping configuration
  • -
  • The generated database view (if selected above)
  • +