diff --git a/applications/Unity.GrantManager/.env.example b/applications/Unity.GrantManager/.env.example index c937e86596..42e07659d4 100644 --- a/applications/Unity.GrantManager/.env.example +++ b/applications/Unity.GrantManager/.env.example @@ -54,7 +54,7 @@ AuthServer__OidcSignoutCallback="http://localhost:44342/signout-callback-oidc" ##S3__SecretAccessKey="******************" ##S3__ApplicationS3Folder="Unity/Application" ##S3__AssessmentS3Folder="Unity/Adjudication" -##S3__DisallowedFileTypes="[ "exe" , "sh" , "ksh" , "bat" , "cmd" ]" +##S3__AllowedFileTypes=["pdf","doc","docx","xls","xlsx","ppt","pptx","jpg","jpeg","png","gif","txt","csv","zip","odt","ods","odp","rtf","bmp","tif","tiff","webp","heic","heif","eml","msg"] ##S3__MaxFileSize="25" ##S3__EmailAttachmentMaxFileSize="20" ##S3__EmailAttachmentsTotalMaxFileSize="25" diff --git a/applications/Unity.GrantManager/Directory.Build.props b/applications/Unity.GrantManager/Directory.Build.props index 4239024ce7..0e9f5007d6 100644 --- a/applications/Unity.GrantManager/Directory.Build.props +++ b/applications/Unity.GrantManager/Directory.Build.props @@ -10,7 +10,7 @@ - + diff --git a/applications/Unity.GrantManager/common.props b/applications/Unity.GrantManager/common.props index bdf9a7442f..e28f59fbc5 100644 --- a/applications/Unity.GrantManager/common.props +++ b/applications/Unity.GrantManager/common.props @@ -19,7 +19,7 @@ - + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs index 4fea2802d2..1ee1d837bc 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs @@ -1,69 +1,6 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - namespace Unity.AI.Responses; public class FormWorksheetResponse { public string Worksheet { get; set; } = string.Empty; - - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("title")] - public string Title { get; set; } = string.Empty; - - [JsonPropertyName("version")] - public uint Version { get; set; } = 1; - - [JsonPropertyName("published")] - public bool Published { get; set; } - - [JsonPropertyName("reportColumns")] - public string ReportColumns { get; set; } = string.Empty; - - [JsonPropertyName("reportKeys")] - public string ReportKeys { get; set; } = string.Empty; - - [JsonPropertyName("reportViewName")] - public string ReportViewName { get; set; } = string.Empty; - - [JsonPropertyName("sections")] - public List Sections { get; set; } = []; -} - -public class FormWorksheetSectionResponse -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("order")] - public uint Order { get; set; } - - [JsonPropertyName("fields")] - public List Fields { get; set; } = []; -} - -public class FormWorksheetFieldResponse -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("key")] - public string Key { get; set; } = string.Empty; - - [JsonPropertyName("label")] - public string Label { get; set; } = string.Empty; - - [JsonPropertyName("type")] - public int Type { get; set; } - - [JsonPropertyName("order")] - public uint Order { get; set; } - - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } = true; - - [JsonPropertyName("definition")] - public string? Definition { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj index d086c0cf1d..2222c49ef6 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj @@ -6,15 +6,15 @@ Unity.AI - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index 0f17bbd1cd..23d9ed10da 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -129,18 +129,10 @@ public static AIResponseValidationResult ValidateFormWorksheetJson(string respon return AIResponseValidationResult.Invalid("Form worksheet response was not valid JSON."); } - if (!root.TryGetProperty("title", out var title) - || title.ValueKind != JsonValueKind.String - || string.IsNullOrWhiteSpace(title.GetString())) + if (!root.TryGetProperty("fields", out var fields) + || fields.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid("Form worksheet response is missing a non-empty 'title'."); - } - - if (!root.TryGetProperty("sections", out var sections) - || sections.ValueKind != JsonValueKind.Array - || sections.GetArrayLength() == 0) - { - return AIResponseValidationResult.Invalid("Form worksheet response must include at least one section."); + return AIResponseValidationResult.Invalid("Form worksheet response must include a 'fields' array."); } return AIResponseValidationResult.Success(); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 044da04dc5..7df54a2a2a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -848,8 +848,8 @@ Return only valid JSON in the exact mapping shape requested. // ── v2/form-worksheet.system.txt ─────────────────────────────────────── private const string FormWorksheetSystemV2 = """ - You are a worksheet definition generator for Unity Grant Manager. - Generate a recommended worksheet definition JSON that can be used to create a Flex worksheet. + You are a custom-field suggestion generator for Unity Grant Manager. + Recommend only the additional fields needed for a Flex worksheet. Return only valid JSON. """; @@ -860,47 +860,30 @@ Return only valid JSON. OUTPUT { - "Name": "", - "Title": "", - "Version": , - "Published": true, - "Sections": [ - { - "Name": "", - "Order": 1, - "Fields": [ - { - "Name": "", - "Key": "", - "Label": "", - "Type": , - "Definition": "" - } - ] - } - ], - "ReportColumns": "", - "ReportKeys": "", - "ReportViewName": "" + "fields": [ + { "key": "", "label": "", "type": "Text" } + ] } Rules: - - Return one worksheet definition JSON object only. + - Return one field-suggestion JSON object only. - chefsFields contains the available CHEFS source fields. - unityCoreFields contains existing Unity core fields. Do not create a custom field when one of these already fits. - - existingMapping contains any current confirmed Unity-to-CHEFS mappings. Do not duplicate those mappings with a custom field. - - existingWorksheets contains the previous AI worksheet definition, if one exists. Refine it rather than duplicating its custom fields. + - existingMapping contains the current saved Unity-to-CHEFS mappings. Do not duplicate those mappings with a custom field. + - existingCustomFields is a flattened list of fields from worksheets currently linked to this form version. Each entry includes its worksheet name, field name, label, and type. Do not create duplicate custom fields. - formSchema contains detailed CHEFS control configuration when labels and types need more context. - Use the provided form context to decide which custom fields are genuinely needed. - Prefer existing Unity core fields when they already satisfy the need. - Only create additional worksheet custom fields when the form genuinely needs them. - - Keep the worksheet structure valid for Flex. + - Do not include a worksheet title, sections, order, publish state, reporting fields, enabled flag, or field definition. + - Each key and label must be non-empty. Do not repeat a key. + - type must be one of: Text, TextArea, Numeric, Currency, Date, DateTime, Email, Phone, YesNo, Checkbox. Use the type name, never a number. - Return valid plain JSON only. """; private const string FormWorksheetMetadataV2 = """ { - "DATA": "Serialized JSON payload containing form metadata, CHEFS fields, Unity core fields, the current mapping, form schema, and the existing AI worksheet." + "DATA": "Serialized JSON payload containing form metadata, CHEFS fields, Unity core fields, the current mapping, form schema, and custom fields from worksheets currently linked to the form version." } """; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj index 2be3ad05e5..91c741e446 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj @@ -6,22 +6,21 @@ Unity.AI - + - + - - - + + + - - - - - - - - + + + + + + + @@ -29,8 +28,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj index f094575dec..30508215fd 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj @@ -10,12 +10,12 @@ - - + + - + @@ -24,8 +24,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj index b37df03bc2..4b5d57bd20 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj @@ -12,16 +12,14 @@ - + - - - - + + - + @@ -40,8 +38,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Unity.Flex.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Unity.Flex.Application.Contracts.csproj index 259d86d1c9..0b0a20199a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Unity.Flex.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Unity.Flex.Application.Contracts.csproj @@ -9,13 +9,13 @@ - - - - - - - + + + + + + + @@ -23,8 +23,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Unity.Flex.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Unity.Flex.Application.csproj index 331e21863e..43505d8f82 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Unity.Flex.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Unity.Flex.Application.csproj @@ -9,24 +9,24 @@ - - - - - + + + + + - - - - - + + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Unity.Flex.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Unity.Flex.Shared.csproj index 9963d2d2bd..7da95766a1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Unity.Flex.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Unity.Flex.Shared.csproj @@ -10,15 +10,15 @@ - - - - - + + + + + - + @@ -30,8 +30,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Worksheets/Definitions/DefinitionResolver.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Worksheets/Definitions/DefinitionResolver.cs index 3d184fac84..28ac11f104 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Worksheets/Definitions/DefinitionResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Worksheets/Definitions/DefinitionResolver.cs @@ -60,20 +60,20 @@ public static string Resolve(CustomFieldType type, object? definition) { CustomFieldType.Undefined => "{}", CustomFieldType.BCAddress => "{}", - CustomFieldType.Numeric => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.Text => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.Date => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.DateTime => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.Currency => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.YesNo => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.Email => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.Phone => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.Radio => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.Checkbox => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.CheckboxGroup => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.SelectList => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.TextArea => JsonSerializer.Serialize(element.ToString()), - CustomFieldType.DataGrid => JsonSerializer.Serialize(element.ToString()), + CustomFieldType.Numeric => element.GetRawText(), + CustomFieldType.Text => element.GetRawText(), + CustomFieldType.Date => element.GetRawText(), + CustomFieldType.DateTime => element.GetRawText(), + CustomFieldType.Currency => element.GetRawText(), + CustomFieldType.YesNo => element.GetRawText(), + CustomFieldType.Email => element.GetRawText(), + CustomFieldType.Phone => element.GetRawText(), + CustomFieldType.Radio => element.GetRawText(), + CustomFieldType.Checkbox => element.GetRawText(), + CustomFieldType.CheckboxGroup => element.GetRawText(), + CustomFieldType.SelectList => element.GetRawText(), + CustomFieldType.TextArea => element.GetRawText(), + CustomFieldType.DataGrid => element.GetRawText(), _ => throw new NotImplementedException(), }; } @@ -216,4 +216,4 @@ public static bool ResolveIsDynamic(CustomFieldDefinition field) }; } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj index 1b9333d47f..f51c9ebdb9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj @@ -12,13 +12,13 @@ - + - + - + @@ -55,8 +55,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj index fbe7add032..16ef810103 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -12,20 +12,22 @@ - - - - - - - - - + + + + + + + + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/DefinitionResolverTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/DefinitionResolverTests.cs new file mode 100644 index 0000000000..6a065af65e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/DefinitionResolverTests.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Shouldly; +using Unity.Flex; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Definitions; +using Xunit; + +namespace Unity.Flex.Application.Tests.Worksheets; + +public class DefinitionResolverTests +{ + [Fact] + public void Resolve_Should_Preserve_JsonObject_When_Definition_Is_JsonElement() + { + using var document = JsonDocument.Parse("""{"required":true,"maxLength":100}"""); + + var definition = DefinitionResolver.Resolve(CustomFieldType.Text, document.RootElement); + + definition.ShouldBe("""{"required":true,"maxLength":100}"""); + definition.ConvertDefinition(CustomFieldType.Text)!.Required.ShouldBeTrue(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj index bc1afd5896..840463a334 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,30 +9,30 @@ - - - + + + all runtime; build; native; contentfiles; analyzers - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj index ce4d41158a..dac96e2ab9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,24 +10,19 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj index ac7d62ebe2..86d284e211 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj @@ -31,17 +31,15 @@ - - - + + + - - - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj index 4f55729056..cb31380f07 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,16 +10,16 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj index 0e41f2f1c4..cb37cfb67e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj @@ -1,34 +1,33 @@ - - - - - - netstandard2.1;net10.0 - enable - Unity.Notifications - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - + + + + + + netstandard2.1;net10.0 + enable + Unity.Notifications + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs index 42c22f5d17..a68c3109cc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using Unity.Notifications.Emails; +using Volo.Abp.Authorization; using Volo.Abp.DependencyInjection; using Volo.Abp.Users; @@ -77,13 +78,19 @@ public async Task UploadAttachmentAsync( ContentType = contentType, FileSize = fileContent.Length, Time = DateTime.UtcNow, + // Unlike UploadUserAttachmentAsync below, this path is reached from + // EmailNotificationHandler - a local event handler that can run for + // system/schedule-triggered emails with no interactive user in context, so a missing + // ICurrentUser.Id here isn't necessarily an error condition. The caller already wraps + // this in a try/catch that logs and sends the email without the attachment on any + // failure, so Guid.Empty (rather than throwing) is the intentional "no user" marker. UserId = _currentUser.Id ?? Guid.Empty, TenantId = tenantId }; await _emailLogAttachmentRepository.InsertAsync(attachment); return attachment; - } + } public async Task DownloadFromS3Async(string s3ObjectKey) { @@ -143,7 +150,10 @@ public async Task UploadUserAttachmentAsync( ContentType = contentType, FileSize = fileContent.Length, Time = DateTime.UtcNow, - UserId = _currentUser.Id ?? Guid.Empty, + // A missing ICurrentUser.Id means this was reached without an authenticated user - + // fail loudly rather than silently attributing the attachment to Guid.Empty, which + // would look like a valid, specific user rather than an error state. + UserId = _currentUser.Id ?? throw new AbpAuthorizationException("Cannot save an email attachment without an authenticated user."), TenantId = tenantId }; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs index 1933d5e6da..d5db9a21e3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs @@ -122,6 +122,12 @@ public async Task GetTotalFileSizeByEmailLogIdAsync(Guid? emailLogId, Guid return await emailAttachmentService.GetTotalFileSizeAsync(emailLogId, templateId); } + // Not exposed over HTTP: this method takes raw fileName/content/contentType with none of the + // allowlist/size/content-type validation AttachmentController enforces before calling it. It + // must only ever be reached in-process, via IEmailLogAttachmentUploadService, from a caller + // (AttachmentController) that has already run those checks - never directly by an HTTP client, + // which would bypass validation entirely despite still needing the Email.Send permission. + [RemoteService(false)] public async Task UploadAsync(Guid? emailLogId, Guid? templateId, Guid? tenantId, string fileName, byte[] content, string contentType) { var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, templateId, tenantId, fileName, content, contentType); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs index 6a954be51a..635e7c1034 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs @@ -36,7 +36,7 @@ public async Task SendToEmailDelayedQueueAsync(EmailNotificationEvent emai await Task.Delay(TimeSpan.FromMilliseconds(FiveMinutesInMilliSeconds * (emailNotificationEvent.RetryAttempts + 1))); - _queueProducer.PublishMessage(message); + await _queueProducer.PublishMessageAsync(message); } catch (Exception ex) { var ExceptionMessage = ex.Message; @@ -45,7 +45,7 @@ public async Task SendToEmailDelayedQueueAsync(EmailNotificationEvent emai return Task.CompletedTask; } - public Task SendToEmailEventQueueAsync(EmailNotificationEvent emailNotificationEvent) + public async Task SendToEmailEventQueueAsync(EmailNotificationEvent emailNotificationEvent) { try { @@ -55,13 +55,11 @@ public Task SendToEmailEventQueueAsync(EmailNotificationEvent emailNotificationE TenantId = emailNotificationEvent.TenantId ?? Guid.Empty, EmailNotificationEvent = emailNotificationEvent }; - _queueProducer.PublishMessage(message); + await _queueProducer.PublishMessageAsync(message); } catch (Exception ex) { var ExceptionMessage = ex.Message; _logger.LogError(ex, "SendToEmailEventQueueAsync Exception: {ExceptionMessage}", ExceptionMessage); } - - return Task.CompletedTask; } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj index 0e50decfcd..7a8eacb9a4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj @@ -9,21 +9,19 @@ - - - - - - + + + + + + - - - - - - - - + + + + + + @@ -31,8 +29,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj index 2eb1af44f6..5dcad10b53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj @@ -10,14 +10,14 @@ - - - - + + + + - + @@ -26,8 +26,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj index a4cf76b437..926f8cad23 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj @@ -9,16 +9,16 @@ - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj index 35443ef745..02618456b9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj @@ -9,16 +9,16 @@ - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj index 451ddaea79..c8c944ee7e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj @@ -9,9 +9,9 @@ - - - + + + @@ -21,8 +21,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj index 4c17656712..7e309f547b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj @@ -9,15 +9,15 @@ - - - + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj index a6e97532ec..f9bdac728d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj @@ -10,8 +10,8 @@ - - + + @@ -23,8 +23,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj index 3e110d2d7d..b5223b81ad 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj @@ -12,15 +12,13 @@ - + - - - - - - - + + + + + @@ -29,7 +27,7 @@ - + @@ -56,8 +54,8 @@ - - + + all runtime; build; native; contentfiles; analyzers 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 1fc31ed1c2..015e4a3e13 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 @@ -63,7 +63,7 @@ - + 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 651fe52ea2..54e1360715 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 @@ -853,7 +853,17 @@ $(function () { const input = document.getElementById(inputId); if (!input?.files?.length) return; - const disallowedTypes = JSON.parse(decodeURIComponent($('#Extensions').val())); + let allowedTypes; + try { + allowedTypes = JSON.parse(decodeURIComponent($('#AllowedFileTypes').val())); + if (!Array.isArray(allowedTypes)) { + throw new TypeError('AllowedFileTypes did not parse to an array'); + } + } catch (e) { + console.warn('Unable to parse allowed file types configuration:', e); + abp.notify.error('Unable to determine allowed file types. Please contact support.'); + return; + } const maxFileSize = decodeURIComponent($('#EmailAttachmentMaxFileSize').val()); let isAllowedTypeError = false; @@ -862,7 +872,7 @@ $(function () { for (let file of input.files) { const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase(); - if (disallowedTypes.includes(ext)) { + if (!allowedTypes.includes(ext)) { isAllowedTypeError = true; } if (file.size * 0.000001 > maxFileSize) { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs index 1e3f2b7f71..0fc790c3ae 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs @@ -31,7 +31,7 @@ public virtual async Task InvokeAsync() EnableEmailDelay = string.Equals( await settingProvider.GetOrNullAsync(Notifications.Settings.NotificationsSettings.Mailing.EnableEmailDelay), "true", System.StringComparison.OrdinalIgnoreCase), - Extensions = configuration["S3:DisallowedFileTypes"] ?? "", + AllowedFileTypes = configuration["S3:AllowedFileTypes"] ?? "", MaxFileSize = configuration["S3:MaxFileSize"] ?? "", EmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentMaxFileSize"] ?? "", TotalEmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25" diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs index e9497f02ab..a54ae3529d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs @@ -18,7 +18,7 @@ public class NotificationsSettingViewModel [Display(Name = "Enable Schedule Email for Individual Application")] public bool EnableEmailDelay { get; set; } - public string Extensions { get; set; } = string.Empty; + public string AllowedFileTypes { get; set; } = string.Empty; public string MaxFileSize { get; set; } = string.Empty; public string EmailAttachmentMaxFileSize { get; set; } = string.Empty; public string TotalEmailAttachmentMaxFileSize { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/Unity.Notifications.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/Unity.Notifications.Application.Tests.csproj index 94784c7e73..074a012509 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/Unity.Notifications.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/Unity.Notifications.Application.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -11,16 +11,14 @@ - - - - - + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Domain.Tests/Unity.Notifications.Domain.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Domain.Tests/Unity.Notifications.Domain.Tests.csproj index 721959b2a2..1c8aed8f6f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Domain.Tests/Unity.Notifications.Domain.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Domain.Tests/Unity.Notifications.Domain.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -9,16 +9,16 @@ - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj index 1c140ce8e0..11a14c5d66 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -9,21 +9,23 @@ - - - - - + + + + + - - - + + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.TestBase/Unity.Notifications.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.TestBase/Unity.Notifications.TestBase.csproj index 2969eb286a..46461eb287 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.TestBase/Unity.Notifications.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.TestBase/Unity.Notifications.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,31 +9,31 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Unity.Payments.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Unity.Payments.Application.Contracts.csproj index d8a8029060..751dba9260 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Unity.Payments.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Unity.Payments.Application.Contracts.csproj @@ -9,16 +9,16 @@ - - - - - - - - - - + + + + + + + + + + @@ -27,8 +27,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs index ddefff28db..0960c1a8fb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs @@ -32,37 +32,37 @@ public class PaymentsManager( private void ConfigureWorkflow(StateMachine paymentStateMachine) { paymentStateMachine.Configure(PaymentRequestStatus.L1Pending) - .PermitIf(PaymentApprovalAction.L1Approve, PaymentRequestStatus.L2Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L1ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.L1Decline, PaymentRequestStatus.L1Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L1ApproveOrDecline).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.L1Approve, PaymentRequestStatus.L2Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L1ApproveOrDecline)) + .PermitIfAsync(PaymentApprovalAction.L1Decline, PaymentRequestStatus.L1Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L1ApproveOrDecline)); paymentStateMachine.Configure(PaymentRequestStatus.L1Declined) - .PermitIf(PaymentApprovalAction.L1Approve, PaymentRequestStatus.L2Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L1ApproveOrDecline).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.L1Approve, PaymentRequestStatus.L2Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L1ApproveOrDecline)); paymentStateMachine.Configure(PaymentRequestStatus.L2Pending) - .PermitIf(PaymentApprovalAction.L2Approve, PaymentRequestStatus.L3Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.L2Decline, PaymentRequestStatus.L2Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.L2Approve, PaymentRequestStatus.L3Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline)) + .PermitIfAsync(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline)) + .PermitIfAsync(PaymentApprovalAction.L2Decline, PaymentRequestStatus.L2Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline)); paymentStateMachine.Configure(PaymentRequestStatus.L2Declined) - .PermitIf(PaymentApprovalAction.L2Approve, PaymentRequestStatus.L3Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.L2Approve, PaymentRequestStatus.L3Pending, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline)) + .PermitIfAsync(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline)); paymentStateMachine.Configure(PaymentRequestStatus.L3Pending) - .PermitIf(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.L3Decline, PaymentRequestStatus.L3Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline)) + .PermitIfAsync(PaymentApprovalAction.L3Decline, PaymentRequestStatus.L3Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline)) + .PermitIfAsync(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment)); paymentStateMachine.Configure(PaymentRequestStatus.L2Declined) - .PermitIf(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline)); paymentStateMachine.Configure(PaymentRequestStatus.L1Pending) - .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment)); paymentStateMachine.Configure(PaymentRequestStatus.L2Pending) - .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment)); paymentStateMachine.Configure(PaymentRequestStatus.HistoricalPayment) - .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + .PermitIfAsync(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment)); } private async Task HasPermissionAsync(string permission) @@ -79,7 +79,7 @@ public async Task> GetActions(Guid paymentRequests s => paymentRequest.SetPaymentRequestStatus(s), ConfigureWorkflow); var allActions = Workflow.GetAllActions().Distinct().ToList(); - var permittedActions = Workflow.GetPermittedActions().ToList(); + var permittedActions = (await Workflow.GetPermittedActionsAsync()).ToList(); var actionsList = allActions .Select(trigger => diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/Workflow.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/Workflow.cs index 6b977cdb2f..c5d83c9aba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/Workflow.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/Workflow.cs @@ -1,5 +1,6 @@ using Stateless; using System; +using System.Linq; using System.Threading.Tasks; using Volo.Abp; @@ -40,7 +41,7 @@ public virtual TStates GetState() public virtual async Task ExecuteActionAsync(TTriggers action) { - if (_stateMachine.CanFire(action)) + if ((await _stateMachine.GetPermittedTriggersAsync()).Contains(action)) { await _stateMachine.FireAsync(action); } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/WorkflowExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/WorkflowExtensions.cs index 607d7c6f2b..7ee5eef862 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/WorkflowExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Workflow/WorkflowExtensions.cs @@ -3,6 +3,7 @@ using Stateless.Graph; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; namespace Unity.Payments.Domain.Workflow; public static class UnityWorkflowExtensions @@ -10,9 +11,9 @@ public static class UnityWorkflowExtensions /// /// The currently permitted actions allowed by the workflow state machine. /// - public static IEnumerable GetPermittedActions(this PaymentsWorkflow workflow) + public static async Task> GetPermittedActionsAsync(this PaymentsWorkflow workflow) { - return workflow._stateMachine.GetPermittedTriggers(); + return await workflow._stateMachine.GetPermittedTriggersAsync(); } /// diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/PaymentQueueService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/PaymentQueueService.cs index e4df693638..e644787b61 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/PaymentQueueService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/PaymentQueueService.cs @@ -19,15 +19,13 @@ public PaymentQueueService( _reconcilePaymentQueueProducer = reconcilePaymentQueueProducer; } - public Task SendPaymentToInvoiceQueueAsync(InvoiceMessages message) + public async Task SendPaymentToInvoiceQueueAsync(InvoiceMessages message) { - _invoiceQueueProducer.PublishMessage(message); - return Task.CompletedTask; + await _invoiceQueueProducer.PublishMessageAsync(message); } - public Task SendPaymentToReconciliationQueueAsync(ReconcilePaymentMessages message) + public async Task SendPaymentToReconciliationQueueAsync(ReconcilePaymentMessages message) { - _reconcilePaymentQueueProducer.PublishMessage(message); - return Task.CompletedTask; + await _reconcilePaymentQueueProducer.PublishMessageAsync(message); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj index 86f2b532cf..35971f1145 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj @@ -9,29 +9,26 @@ - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - + @@ -40,8 +37,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Unity.Payments.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Unity.Payments.Shared.csproj index 03b6952acd..8042585bb1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Unity.Payments.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Unity.Payments.Shared.csproj @@ -10,16 +10,16 @@ - - - - - - + + + + + + - + @@ -31,8 +31,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj index 35a005262d..3c081bf602 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj @@ -12,17 +12,17 @@ - - + + - - - - + + + + - + @@ -66,8 +66,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj index b935efb804..5fae67c71e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -13,21 +13,23 @@ - - - - - + + + + + + + + + - - - - + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj index 5ed17173ab..929c1d8534 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,46 +9,43 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj index c5e4928327..6680bdf353 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj @@ -9,14 +9,14 @@ - - + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs index 08593ddb8a..6d3cb324d5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs @@ -316,7 +316,7 @@ public async Task DeleteViewAsync(string viewName) // SECURITY: Use pre-validated identifier in quoted format // The identifier has been validated above, and we use quoted format to prevent injection var sql = $"DROP VIEW IF EXISTS \"Reporting\".\"{normalizedViewName}\""; - await dbContext.Database.ExecuteSqlRawAsync(SafeguardSql(sql)); + await dbContext.Database.ExecuteSqlRawAsync(sql); } finally { @@ -352,7 +352,7 @@ public async Task AssignRoleToViewAsync(string role, string viewName) { // Use ExecuteSqlRaw with properly quoted identifiers - safer than string concatenation var sql = $"GRANT SELECT ON \"Reporting\".\"{normalizedViewName}\" TO \"{role}\""; - await dbContext.Database.ExecuteSqlRawAsync(SafeguardSql(sql)); + await dbContext.Database.ExecuteSqlRawAsync(sql); } finally { @@ -400,7 +400,7 @@ FROM pg_views foreach (var viewName in viewNames) { var sql = $"GRANT SELECT ON \"Reporting\".\"{viewName}\" TO \"{role}\""; - await dbContext.Database.ExecuteSqlRawAsync(SafeguardSql(sql)); + await dbContext.Database.ExecuteSqlRawAsync(sql); } } finally @@ -546,51 +546,6 @@ FROM information_schema.views } } - /// - /// Safeguards SQL strings by validating they only contain safe, pre-validated identifiers - /// and preventing SQL injection through strict identifier validation. - /// - /// The SQL string to validate - should only contain pre-validated PostgreSQL identifiers - /// The validated SQL string if safe - /// Thrown if the SQL contains potentially unsafe content - private static string SafeguardSql(string sql) - { - if (string.IsNullOrWhiteSpace(sql)) - { - throw new ArgumentException("SQL cannot be null or empty", nameof(sql)); - } - - // This method is specifically for our controlled scenarios where: - // 1. All identifiers have been pre-validated using IsValidPostgreSqlIdentifier() - // 2. The SQL structure is fixed and known (DROP VIEW, GRANT SELECT) - // 3. Only the identifier names are dynamic (view name, role name) - - // Additional safety check: ensure the SQL only contains expected patterns - // for our specific use cases (DROP VIEW and GRANT SELECT statements) - if (!IsKnownSafeSqlPattern(sql)) - { - throw new ArgumentException("SQL does not match expected safe patterns", nameof(sql)); - } - - return sql; - } - - /// - /// Validates that the SQL string matches one of our known safe patterns - /// - /// The SQL string to validate - /// True if the SQL matches a known safe pattern - private static bool IsKnownSafeSqlPattern(string sql) - { - if (string.IsNullOrWhiteSpace(sql)) - return false; - - // For our specific use cases, we expect either a DROP VIEW or GRANT SELECT statement - // The view name and roles have been pre-validated, so we just check the overall structure here - - return true; - } - /// /// Validates that a string is a valid PostgreSQL identifier to prevent SQL injection /// diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj index d9f72545c4..12e53e814e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj @@ -9,13 +9,13 @@ - - - + + + - - - + + + @@ -23,8 +23,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj index aa2aa2c826..2640f8a1fc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj @@ -10,12 +10,12 @@ - - + + - + @@ -24,8 +24,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Unity.Reporting.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Unity.Reporting.Web.csproj index 4798a9d065..c1ea57222d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Unity.Reporting.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Unity.Reporting.Web.csproj @@ -12,16 +12,16 @@ - + - - - - + + + + - + @@ -55,8 +55,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj index c7c03b191a..401ab64c45 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -11,13 +11,13 @@ - - + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj index 8cd7e3b79e..b3d4492f8b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers @@ -18,19 +18,19 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs index 45b2b86846..54a2c1e379 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; @@ -15,16 +16,18 @@ public sealed class PooledChannelProvider( private readonly IConnectionProvider _connectionProvider = connectionProvider ?? throw new ArgumentNullException(nameof(connectionProvider)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly int _maxChannels = maxChannels; - private readonly ConcurrentQueue _channelPool = new(); + private readonly ConcurrentQueue _channelPool = new(); private int _currentChannelCount; private bool _disposed; private const int DefaultMaxChannels = 1000; /// - /// Get a channel from the pool or create a new one if under max limit + /// Get a channel from the pool or create a new one if under max limit. + /// Channels are created with publisher confirmations enabled so producers can + /// rely on awaiting broker confirmation. /// - public IModel? GetChannel() + public async Task GetChannelAsync() { ThrowIfDisposed(); @@ -40,10 +43,11 @@ public sealed class PooledChannelProvider( { try { - var connection = _connectionProvider.GetConnection(); + var connection = await _connectionProvider.GetConnectionAsync(); if (connection != null && connection.IsOpen) { - return connection.CreateModel(); + return await connection.CreateChannelAsync( + new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true)); } _logger.LogWarning("RabbitMQ connection is not open."); @@ -67,7 +71,7 @@ public sealed class PooledChannelProvider( /// /// Return a channel to the pool /// - public void ReturnChannel(IModel channel) + public void ReturnChannel(IChannel channel) { if (_disposed || channel == null) { @@ -82,11 +86,10 @@ public void ReturnChannel(IModel channel) DisposeChannel(channel); } - private void DisposeChannel(IModel channel) + private void DisposeChannel(IChannel channel) { if (channel == null) return; - try { if (channel.IsOpen) channel.Close(); } catch (Exception ex) { _logger.LogWarning(ex, "Error closing channel."); } try { channel.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "Error disposing channel."); } Interlocked.Decrement(ref _currentChannelCount); diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs index bebc9a7531..37da8dd4fe 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs @@ -1,55 +1,78 @@ - + using Microsoft.Extensions.Logging; using RabbitMQ.Client; using System; +using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ { - public sealed class ConnectionProvider : IDisposable, IConnectionProvider + public sealed class ConnectionProvider : IAsyncDisposable, IDisposable, IConnectionProvider { private readonly ILogger _logger; - private readonly IAsyncConnectionFactory _connectionFactory; + private readonly IConnectionFactory _connectionFactory; private IConnection? _connection; - public ConnectionProvider(ILogger logger, IAsyncConnectionFactory connectionFactory) + public ConnectionProvider(ILogger logger, IConnectionFactory connectionFactory) { _logger = logger; _connectionFactory = connectionFactory; } - public void Dispose() + public async ValueTask DisposeAsync() { + if (_connection == null) return; + try { - if (_connection != null && _connection.IsOpen) + if (_connection.IsOpen) { _logger.LogDebug("Closing the connection"); - _connection.Close(); - _connection.Dispose(); + await _connection.CloseAsync(); } } catch (Exception ex) + { + _logger.LogCritical(ex, "Cannot close RabbitMq connection"); + } + finally + { + // Always dispose, even if the connection was already closed or faulted. + await _connection.DisposeAsync(); + } + } + + // Implemented alongside IAsyncDisposable so the DI container can dispose this + // singleton whether it is torn down synchronously or asynchronously. + public void Dispose() + { + try + { + _connection?.Dispose(); + } + catch (Exception ex) { _logger.LogCritical(ex, "Cannot dispose RabbitMq channel or connection"); } } - public IConnection? GetConnection() + public async Task GetConnectionAsync() { if (_connection == null || !_connection.IsOpen) { _logger.LogDebug("Open RabbitMQ connection"); try { - _connection = _connectionFactory.CreateConnection(); - } catch (Exception ex) { + _connection = await _connectionFactory.CreateConnectionAsync(); + } + catch (Exception ex) + { var ExceptionMessage = ex.Message; _logger.LogError(ex, "ConnectionProvider - Exception: {ConnectionProvider}", ExceptionMessage); - } + } } return _connection; } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs index a57ca8acfb..075245012e 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs @@ -1,11 +1,12 @@ -using RabbitMQ.Client; +using RabbitMQ.Client; using System; +using System.Threading.Tasks; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { public interface IChannelProvider : IDisposable { - IModel? GetChannel(); - void ReturnChannel(IModel channel); + Task GetChannelAsync(); + void ReturnChannel(IChannel channel); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs index d447bd328f..ce82cfedd3 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs @@ -1,9 +1,10 @@ -using RabbitMQ.Client; +using RabbitMQ.Client; +using System.Threading.Tasks; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { public interface IConnectionProvider { - IConnection? GetConnection(); + Task GetConnectionAsync(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs index 8b0ca37b55..1baa6ebc8c 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using RabbitMQ.Client; #pragma warning disable CA1005 // Avoid excessive parameters on generic types @@ -13,7 +14,15 @@ public interface IQueueChannelProvider : IDisposable where TQueue /// /// Gets a channel for publishing or consuming messages. /// - IModel GetChannel(); + Task GetChannelAsync(); + + /// + /// Returns a channel obtained from so it can be pooled + /// or disposed and its throttling permit released. Callers that finish with a channel + /// (for example a one-off publish) must return it; long-lived consumer channels are + /// kept open and are not returned until the consumer is torn down. + /// + void ReturnChannel(IChannel channel); } } #pragma warning restore CA1005 // Avoid excessive parameters on generic types diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs index d58d743d7d..e3fd333eec 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs @@ -1,11 +1,13 @@ -namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { #pragma warning disable S2326 public interface IQueueConsumerHandler where TMessageConsumer : IQueueConsumer where TQueueMessage : class, IQueueMessage { - void RegisterQueueConsumer(); + Task RegisterQueueConsumerAsync(); - void CancelQueueConsumer(); + Task CancelQueueConsumerAsync(); } #pragma warning restore S2326 -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs index d389f2d34f..5aa66197ac 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs @@ -1,7 +1,9 @@ -namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { public interface IQueueProducer where TQueueMessage : IQueueMessage { - void PublishMessage(TQueueMessage message); + Task PublishMessageAsync(TQueueMessage message); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index 1eda5b9327..9be537782d 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants; @@ -14,14 +15,14 @@ public sealed class PooledQueueChannelProvider : IQueueChannelPro { private readonly IChannelProvider _channelProvider; private readonly ILogger> _logger; - private readonly ConcurrentQueue _channelPool = new(); + private readonly ConcurrentQueue _channelPool = new(); private readonly SemaphoreSlim _channelSemaphore = new(MaxChannels, MaxChannels); private readonly Timer _cleanupTimer; private readonly string _queueName = typeof(TQueueMessage).Name; private volatile bool _disposed; private volatile bool _queueDeclared; - private readonly object _queueDeclareLock = new(); + private readonly SemaphoreSlim _queueDeclareLock = new(1, 1); private const int MaxChannels = 5000; private readonly TimeSpan _channelWaitTimeout = TimeSpan.FromSeconds(10); @@ -37,11 +38,11 @@ public PooledQueueChannelProvider( TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); } - public IModel GetChannel() + public async Task GetChannelAsync() { ObjectDisposedException.ThrowIf(_disposed, nameof(PooledQueueChannelProvider)); - if (!_channelSemaphore.Wait(_channelWaitTimeout)) + if (!await _channelSemaphore.WaitAsync(_channelWaitTimeout)) { throw new TimeoutException( $"Unable to acquire a channel for queue {_queueName} within {_channelWaitTimeout.TotalSeconds} seconds."); @@ -59,8 +60,8 @@ public IModel GetChannel() } // Create new channel - var channel = _channelProvider.GetChannel() ?? throw new InvalidOperationException("Channel cannot be null."); - EnsureQueueDeclared(channel); + var channel = await _channelProvider.GetChannelAsync() ?? throw new InvalidOperationException("Channel cannot be null."); + await EnsureQueueDeclaredAsync(channel); return channel; } catch @@ -70,7 +71,7 @@ public IModel GetChannel() } } - public void ReturnChannel(IModel channel) + public void ReturnChannel(IChannel channel) { if (channel?.IsOpen == true && !_disposed) { @@ -92,28 +93,30 @@ public void ReturnChannel(IModel channel) } } - private void EnsureQueueDeclared(IModel channel) + private async Task EnsureQueueDeclaredAsync(IChannel channel) { if (_queueDeclared) return; - lock (_queueDeclareLock) + await _queueDeclareLock.WaitAsync(); + try { if (_queueDeclared) return; - try - { - DeclareQueue(channel); - _queueDeclared = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to declare queue {QueueName}", _queueName); - throw new InvalidOperationException($"Failed to declare queue '{_queueName}'. See inner exception for details.", ex); - } + await DeclareQueueAsync(channel); + _queueDeclared = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to declare queue {QueueName}", _queueName); + throw new InvalidOperationException($"Failed to declare queue '{_queueName}'. See inner exception for details.", ex); + } + finally + { + _queueDeclareLock.Release(); } } - private void DeclareQueue(IModel channel) + private async Task DeclareQueueAsync(IChannel channel) { try { @@ -121,24 +124,24 @@ private void DeclareQueue(IModel channel) var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}"; // Ensure DLX exchange exists - channel.ExchangeDeclare(dlxName, ExchangeType.Direct, durable: true); + await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); // Ensure DLQ exists and is bound to DLX - channel.QueueDeclare(dlqName, durable: true, exclusive: false, autoDelete: false, - arguments: new Dictionary + await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDelete: false, + arguments: new Dictionary { { "x-queue-type", "quorum" }, { "x-overflow", "reject-publish" } }); - channel.QueueBind(dlqName, dlxName, dlqName); + await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null); // Declare main queue with DLX args - channel.QueueDeclare( + await channel.QueueDeclareAsync( _queueName, durable: true, exclusive: false, autoDelete: false, - arguments: new Dictionary + arguments: new Dictionary { { "x-queue-type", "quorum" }, { "x-overflow", "reject-publish" }, @@ -148,11 +151,11 @@ private void DeclareQueue(IModel channel) { "x-delivery-limit", 10 } }); - BindToExchange(channel); + await BindToExchangeAsync(channel); } catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) { - if (ex.ShutdownReason.ReplyCode == 406 && + if (ex.ShutdownReason?.ReplyCode == 406 && ex.ShutdownReason.ReplyText.Contains("inequivalent arg")) { _logger.LogWarning( @@ -160,7 +163,7 @@ private void DeclareQueue(IModel channel) "Queue {QueueName} exists with incompatible config. Using existing queue in compatibility mode.", _queueName); - BindToExchange(channel); + await BindToExchangeAsync(channel); } else { @@ -169,20 +172,19 @@ private void DeclareQueue(IModel channel) } } - private void BindToExchange(IModel channel) + private async Task BindToExchangeAsync(IChannel channel) { var mainExchange = $"{_queueName}.exchange"; - channel.ExchangeDeclare(mainExchange, ExchangeType.Direct, durable: true); - channel.QueueBind(_queueName, mainExchange, _queueName); + await channel.ExchangeDeclareAsync(mainExchange, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); + await channel.QueueBindAsync(_queueName, mainExchange, _queueName, arguments: null); } - private void DisposeChannel(IModel channel) + private void DisposeChannel(IChannel channel) { if (channel == null) return; try { - if (channel.IsOpen) channel.Close(); channel.Dispose(); } catch (Exception ex) @@ -195,7 +197,7 @@ private void CleanupIdleChannels() { if (_disposed) return; - var channels = new List(); + var channels = new List(); while (_channelPool.TryDequeue(out var channel)) channels.Add(channel); @@ -231,8 +233,9 @@ public void Dispose() DisposeChannel(channel); _channelSemaphore.Dispose(); + _queueDeclareLock.Dispose(); } public string QueueName => _queueName; } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index d64d0a1248..37e07c7048 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -28,21 +28,25 @@ public class QueueConsumerHandler( private string? _consumerTag; private readonly string _consumerName = typeof(TMessageConsumer).Name; - public void RegisterQueueConsumer() + public async Task RegisterQueueConsumerAsync() { _logger.LogInformation("Registering {Consumer} as a consumer for Queue {Queue}", _consumerName, _queueName); using var scope = _serviceProvider.CreateScope(); var channelProvider = scope.ServiceProvider.GetRequiredService>(); - var consumerChannel = channelProvider.GetChannel() ?? throw new QueueingException($"Failed to create consumer channel for {_queueName}"); + var consumerChannel = await channelProvider.GetChannelAsync() ?? throw new QueueingException($"Failed to create consumer channel for {_queueName}"); var consumer = new AsyncEventingBasicConsumer(consumerChannel); - consumer.Received += HandleMessage; + consumer.ReceivedAsync += HandleMessageAsync; try { - _consumerTag = consumerChannel.BasicConsume( + _consumerTag = await consumerChannel.BasicConsumeAsync( queue: _queueName, autoAck: false, + consumerTag: string.Empty, + noLocal: false, + exclusive: false, + arguments: null, consumer: consumer); _logger.LogInformation("Successfully registered {Consumer} as consumer for {Queue}", _consumerName, _queueName); @@ -54,7 +58,7 @@ public void RegisterQueueConsumer() } } - void IQueueConsumerHandler.CancelQueueConsumer() + async Task IQueueConsumerHandler.CancelQueueConsumerAsync() { if (string.IsNullOrEmpty(_consumerTag)) return; @@ -63,25 +67,30 @@ void IQueueConsumerHandler.CancelQueueConsumer( using var scope = _serviceProvider.CreateScope(); var channelProvider = scope.ServiceProvider.GetRequiredService>(); - var channel = channelProvider.GetChannel(); + var channel = await channelProvider.GetChannelAsync(); try { - channel.BasicCancel(_consumerTag); + await channel.BasicCancelAsync(_consumerTag); } catch (Exception ex) { _logger.LogError(ex, "Error canceling consumer {Consumer}", _consumerName); throw new QueueingException($"Error canceling consumer {_consumerName}", ex); } + finally + { + // Return the short-lived cancel channel so it is disposed and its permit released. + channelProvider.ReturnChannel(channel); + } } - private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) + private async Task HandleMessageAsync(object sender, BasicDeliverEventArgs ea) { _logger.LogInformation("Received message on {Queue}", _queueName); using var consumerScope = _serviceProvider.CreateScope(); - var consumingChannel = ((AsyncEventingBasicConsumer)sender).Model; + var consumingChannel = ((AsyncEventingBasicConsumer)sender).Channel; try { @@ -98,7 +107,7 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) else if (tenantedMessage.TenantId == Guid.Empty) { _logger.LogError("Message {MessageId} on {Queue} has an empty TenantId and cannot be processed", message.MessageId, _queueName); - consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); return; } else @@ -106,19 +115,19 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) await ConsumeWithAuditingAsync(consumerScope, tenantedMessage, message); } - consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); _logger.LogInformation("Message {MessageId} successfully processed", message.MessageId); } catch (JsonException jex) { _logger.LogError(jex, "Deserialization failed for message on {Queue}", _queueName); - consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); } catch (Exception ex) { _logger.LogError(ex, "Error processing message on {Queue}", _queueName); - consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs index 6e84a42504..3f7d7b1d2d 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs @@ -36,7 +36,7 @@ public async Task StartAsync(CancellationToken cancellationToken) _scope = _serviceProvider.CreateScope(); _consumerHandler = _scope.ServiceProvider.GetRequiredService>(); - _consumerHandler.RegisterQueueConsumer(); + await _consumerHandler.RegisterQueueConsumerAsync(); _logger.LogInformation("Successfully registered consumer {ConsumerName}", typeof(TMessageConsumer).Name); return; @@ -63,7 +63,7 @@ public async Task StartAsync(CancellationToken cancellationToken) } } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { var QueueServiceName = nameof(QueueConsumerRegistratorService); var ConsumerName = typeof(TMessageConsumer).Name; @@ -73,7 +73,10 @@ public Task StopAsync(CancellationToken cancellationToken) try { - _consumerHandler?.CancelQueueConsumer(); + if (_consumerHandler != null) + { + await _consumerHandler.CancelQueueConsumerAsync(); + } _scope?.Dispose(); } catch (Exception ex) @@ -81,8 +84,6 @@ public Task StopAsync(CancellationToken cancellationToken) var ExceptionMessage = ex.Message; _logger.LogError(ex, "QueueConsumerRegistratorService StopAsync Exception: {ExceptionMessage}", ExceptionMessage); } - - return Task.CompletedTask; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs index 360b71b00b..2af53183cb 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Globalization; using System.Text; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RabbitMQ.Client; @@ -28,7 +29,7 @@ public QueueProducer( _exchangeName = $"{_queueName}.exchange"; } - public void PublishMessage(TQueueMessage message) + public async Task PublishMessageAsync(TQueueMessage message) { if (EqualityComparer.Default.Equals(message, default)) throw new ArgumentNullException(nameof(message)); @@ -36,7 +37,7 @@ public void PublishMessage(TQueueMessage message) if (message.TimeToLive.Ticks <= 0) throw new QueueingException($"{nameof(message.TimeToLive)} cannot be zero or negative"); - var channel = _channelProvider.GetChannel(); + var channel = await _channelProvider.GetChannelAsync(); try { @@ -44,28 +45,24 @@ public void PublishMessage(TQueueMessage message) var serializedMessage = SerializeMessage(message); - var properties = channel.CreateBasicProperties(); - properties.Persistent = true; // quorum queues persist - properties.Type = _queueName; - properties.MessageId = message.MessageId.ToString(); - properties.Expiration = message.TimeToLive.TotalMilliseconds.ToString(CultureInfo.InvariantCulture); - - // Enable publisher confirms once per channel - channel.ConfirmSelect(); + var properties = new BasicProperties + { + Persistent = true, // quorum queues persist + Type = _queueName, + MessageId = message.MessageId.ToString(), + Expiration = message.TimeToLive.TotalMilliseconds.ToString(CultureInfo.InvariantCulture) + }; - channel.BasicPublish( + // Publisher confirmations are enabled on pooled channels, so BasicPublishAsync + // awaits the broker confirmation and throws if the message is not confirmed. + await channel.BasicPublishAsync( exchange: _exchangeName, routingKey: _queueName, + mandatory: false, basicProperties: properties, body: serializedMessage ); - // Wait for confirmation - if (!channel.WaitForConfirms(TimeSpan.FromSeconds(5))) - { - throw new QueueingException($"Publish failed: broker did not confirm message {message.MessageId}"); - } - _logger.LogInformation("Published message {MessageId} to {Queue}", message.MessageId, _queueName); } catch (Exception ex) @@ -73,6 +70,12 @@ public void PublishMessage(TQueueMessage message) _logger.LogError(ex, "PublishMessage Exception: {Message}", ex.Message); throw new QueueingException($"Publish failed: {ex.Message}", ex); } + finally + { + // Return the channel so it is pooled (if still open) or disposed, and its + // throttling permit is released. Without this the channel and permit leak. + _channelProvider.ReturnChannel(channel); + } } private static byte[] SerializeMessage(TQueueMessage message) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs index 2a9190a5fa..f9b82e55a1 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs @@ -12,7 +12,7 @@ public static class QueueingStartupExtensions public static void ConfigureRabbitMQ(this IServiceCollection services) { var configuration = services.GetConfiguration(); - services.TryAddSingleton(provider => + services.TryAddSingleton(provider => { var factory = new ConnectionFactory { @@ -21,10 +21,10 @@ public static void ConfigureRabbitMQ(this IServiceCollection services) HostName = configuration.GetValue("RabbitMQ:HostName") ?? "", VirtualHost = configuration.GetValue("RabbitMQ:VirtualHost") ?? "/", Port = configuration.GetValue("RabbitMQ:Port"), - DispatchConsumersAsync = true, AutomaticRecoveryEnabled = true, - // Configure the amount of concurrent consumers within one host - ConsumerDispatchConcurrency = QueueingConstants.MAX_RABBIT_CONCURRENT_CONSUMERS, + // Configure the amount of concurrent consumers within one host. + // Consumers are dispatched asynchronously by default in the v7 client. + ConsumerDispatchConcurrency = (ushort)QueueingConstants.MAX_RABBIT_CONCURRENT_CONSUMERS, }; return factory; }); diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs index 23fb5ad39f..d392a2d0bc 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs @@ -1,4 +1,5 @@ - + +using System.Threading.Tasks; using Microsoft.Extensions.Options; using RabbitMQ.Client; @@ -12,7 +13,7 @@ public RabbitMQConnection(IOptions rabbitMQOptions) { _rabbitMQOptions = rabbitMQOptions; } - public IConnection GetConnection() + public async Task GetConnectionAsync() { var factory = new ConnectionFactory { @@ -22,7 +23,7 @@ public IConnection GetConnection() Password = _rabbitMQOptions.Value.Password, VirtualHost = _rabbitMQOptions.Value.VirtualHost }; - return factory.CreateConnection(); + return await factory.CreateConnectionAsync(); } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj index 9a51a4f477..bc3d9950fd 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj @@ -9,22 +9,22 @@ - - + + all runtime; build; native; contentfiles; analyzers - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj index 46ef69fa14..df12318742 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj @@ -9,12 +9,12 @@ - - - - - - + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj index 37aa38e406..b97d7e1b7e 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj @@ -9,17 +9,17 @@ - - - - + + + + - - - - - - + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj index 0be2b80c6a..1015353b47 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj index 4be4c28fee..18f4d145bb 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj @@ -12,12 +12,12 @@ - + - + - - + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj index c4e99d3d6c..8b033bd1ce 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj @@ -28,14 +28,14 @@ - + - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj index 48b9e1753a..c0bb2ab2ec 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj @@ -1,4 +1,4 @@ - + latest net10.0 @@ -18,12 +18,12 @@ - - - - - - + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj index 75e6846d56..f63f08c8ef 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj @@ -1,4 +1,4 @@ - + latest net10.0 @@ -13,18 +13,20 @@ - - - - - - + + + + + + + + - - - + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj index 3980e31fd6..378124ba32 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj @@ -1,4 +1,4 @@ - + latest net10.0 @@ -12,19 +12,19 @@ - - - - - + + + + + - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj index fdab8629aa..8e9a68da48 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj @@ -21,12 +21,17 @@ - - - - - - + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/test/Unity.Theme.UX2.Tests/Unity.AspNetCore.Mvc.UI.Theme.UX2.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/test/Unity.Theme.UX2.Tests/Unity.AspNetCore.Mvc.UI.Theme.UX2.Tests.csproj index 4f55729056..cb31380f07 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/test/Unity.Theme.UX2.Tests/Unity.AspNetCore.Mvc.UI.Theme.UX2.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/test/Unity.Theme.UX2.Tests/Unity.AspNetCore.Mvc.UI.Theme.UX2.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,16 +10,16 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormSycnronizationService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormSycnronizationService.cs index 767c9fb7a3..fc37be3b04 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormSycnronizationService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormSycnronizationService.cs @@ -16,7 +16,7 @@ public interface IApplicationFormSycnronizationService : ICrudAppService< Task> GetConnectedApplicationFormsAsync(); Task<(HashSet MissingSubmissions, string MissingSubmissionsReport)> GetMissingSubmissions(int numberOfDaysToCheck); Task> GetChefsSubmissions(ApplicationFormDto applicationFormDto, int numberOfDaysToCheck); - HashSet GetSubmissionsByForm(Guid applicationFormId); + Task> GetSubmissionsByFormAsync(Guid applicationFormId); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs index 3cc7e220d4..ee59592b06 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs @@ -22,7 +22,10 @@ public interface IApplicationFormVersionAppService : ICrudAppService< Task TryInitializeApplicationFormVersion(string? formId, int version, Guid applicationFormId, string formVersionId, bool published); Task GetByChefsFormVersionId(Guid chefsFormVersionId); Task GetFormVersionByApplicationIdAsync(Guid applicationId); - Task DeleteWorkSheetMappingByFormName(string formName, Guid formVersionId); + Task DeleteWorkSheetMappingByFormName(string formName, Guid formVersionId); Task GenerateMappingAsync(Guid id); + Task GetPendingAiWorksheetAsync(Guid formVersionId); + Task CreateAiWorksheetDraftAsync(Guid formVersionId, CreateAiWorksheetDraftDto input); + Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetReviewDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetReviewDto.cs new file mode 100644 index 0000000000..3d0106287b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetReviewDto.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class AiWorksheetReviewDto +{ + public Guid SessionId { get; set; } + public List Fields { get; set; } = []; +} + +public class AiWorksheetReviewFieldDto +{ + public Guid Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public bool Selected { get; set; } = true; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetSuggestionName.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetSuggestionName.cs new file mode 100644 index 0000000000..c85450926a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetSuggestionName.cs @@ -0,0 +1,9 @@ +using System; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public static class AiWorksheetSuggestionName +{ + public static string Build(Guid formId, Guid formVersionId) => + $"ai-form-{formId}-version-{formVersionId}-field-suggestions"; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/CreateAiWorksheetDraftDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/CreateAiWorksheetDraftDto.cs new file mode 100644 index 0000000000..f4c77b1891 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/CreateAiWorksheetDraftDto.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class CreateAiWorksheetDraftDto +{ + public Guid SessionId { get; set; } + + [Required] + public string Title { get; set; } = string.Empty; + + [MinLength(1)] + public List SelectedFieldIds { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj index 7b1c8dcad2..8b5977867e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj @@ -15,13 +15,13 @@ - - - - - - - + + + + + + + **/Assessments/AssessmentListItemDto.cs, **/Assessments/AssessmentScoresDto.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs index 6a454f0345..0f1416ab6c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; -using RestSharp; -using RestSharp.Authenticators; using System; using System.Collections.Generic; using System.Linq; @@ -12,7 +10,9 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Forms; using Unity.GrantManager.Intakes; +using Unity.GrantManager.Integrations; using Unity.GrantManager.Integrations.Chefs; +using Unity.Modules.Shared.Http; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -27,13 +27,13 @@ namespace Unity.GrantManager.ApplicationForms { [RemoteService(false)] - public class ApplicationFormSycnronizationService : + public class ApplicationFormSycnronizationService(IRepository repository) : CrudAppService< ApplicationForm, ApplicationFormDto, Guid, PagedAndSortedResultRequestDto, - CreateUpdateApplicationFormDto>, + CreateUpdateApplicationFormDto>(repository), IApplicationFormSycnronizationService { private static readonly JsonSerializerOptions _submissionSerializerOptions = new() @@ -44,45 +44,23 @@ public class ApplicationFormSycnronizationService : DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - private readonly IStringEncryptionService _stringEncryptionService; - private readonly IApplicationFormRepository _applicationFormRepository; - private readonly ICurrentTenant _currentTenant; - private readonly IApplicationFormSubmissionRepository _applicationFormSubmissionRepository; - private readonly IApplicationFormVersionAppService _applicationFormVersionAppService; - private readonly IFormsApiService _formsApiService; - private readonly IIntakeFormSubmissionManager _intakeFormSubmissionManager; - private readonly INotificationsAppService _notificationsAppService; + // Collaborators are resolved lazily via ABP's LazyServiceProvider to keep the + // constructor within SonarQube's 7-parameter limit (S107). CurrentTenant, Logger, + // and ObjectMapper come from the ApplicationService base class. + private IStringEncryptionService StringEncryptionService => LazyServiceProvider.LazyGetRequiredService(); + private IApplicationFormRepository ApplicationFormRepository => LazyServiceProvider.LazyGetRequiredService(); + private IApplicationFormSubmissionRepository ApplicationFormSubmissionRepository => LazyServiceProvider.LazyGetRequiredService(); + private IApplicationFormVersionAppService ApplicationFormVersionAppService => LazyServiceProvider.LazyGetRequiredService(); + private IFormsApiService FormsApiService => LazyServiceProvider.LazyGetRequiredService(); + private IIntakeFormSubmissionManager IntakeFormSubmissionManager => LazyServiceProvider.LazyGetRequiredService(); + private INotificationsAppService NotificationsAppService => LazyServiceProvider.LazyGetRequiredService(); + private IResilientHttpRequest ResilientHttpRequest => LazyServiceProvider.LazyGetRequiredService(); + private IEndpointManagementAppService EndpointManagementAppService => LazyServiceProvider.LazyGetRequiredService(); + private ITenantRepository TenantRepository => LazyServiceProvider.LazyGetRequiredService(); + private List _facts = []; - private readonly RestClient _intakeClient; - private readonly ITenantRepository _tenantRepository; public List? ApplicationFormDtoList { get; set; } public HashSet FormVersionsInitializedVersionHash { get; set; } = []; - - public ApplicationFormSycnronizationService( - INotificationsAppService notificationsAppService, - ICurrentTenant currentTenant, - IRepository repository, - ITenantRepository tenantRepository, - RestClient restClient, - IStringEncryptionService stringEncryptionService, - IApplicationFormRepository applicationFormRepository, - IApplicationFormSubmissionRepository applicationFormSubmissionRepository, - IApplicationFormVersionAppService applicationFormVersionAppService, - IFormsApiService formsApiService, - IIntakeFormSubmissionManager intakeFormSubmissionManager) - : base(repository) - { - _currentTenant = currentTenant; - _tenantRepository = tenantRepository; - _intakeClient = restClient; - _stringEncryptionService = stringEncryptionService; - _applicationFormRepository = applicationFormRepository; - _formsApiService = formsApiService; - _applicationFormSubmissionRepository = applicationFormSubmissionRepository; - _applicationFormVersionAppService = applicationFormVersionAppService; - _intakeFormSubmissionManager = intakeFormSubmissionManager; - _notificationsAppService = notificationsAppService; - } private async Task SynchronizeFormSubmissions(HashSet missingSubmissions, ApplicationFormDto applicationFormDto) { @@ -108,7 +86,7 @@ private async Task ProcessSingleSubmission(string submissionGuid, ApplicationFor return; } - JObject? submissionData = await _formsApiService.GetSubmissionDataAsync(chefsFormId, chefsSubmissionId); + JObject? submissionData = await FormsApiService.GetSubmissionDataAsync(chefsFormId, chefsSubmissionId); if (submissionData == null) { Logger.LogInformation("ApplicationFormSycnronizationService->SynchronizeFormSubmissions submissionData is null"); @@ -149,7 +127,7 @@ private int GetVersionFromSubmissionData(JObject submissionData) private async Task ProcessFormVersion(string formVersionId, int version, Guid chefsFormId, ApplicationFormDto applicationFormDto, JObject submissionData) { - bool formVersionExists = await _applicationFormVersionAppService.FormVersionExists(formVersionId); + bool formVersionExists = await ApplicationFormVersionAppService.FormVersionExists(formVersionId); string formId = chefsFormId.ToString(); if (!formVersionExists && Guid.TryParse(applicationFormDto.ChefsApplicationFormGuid, out Guid applicationFormIdGuid)) @@ -167,14 +145,14 @@ private async Task InitializeFormVersion(string formId, int version, Guid applic AddFact("Form Version did NOT exist in Unity: ", $"{version}"); AddFact("Version Created: ", "Please Fill in Mapping"); bool published = false; - await _applicationFormVersionAppService.TryInitializeApplicationFormVersion(formId, version, applicationFormIdGuid, formVersionId, published); + await ApplicationFormVersionAppService.TryInitializeApplicationFormVersion(formId, version, applicationFormIdGuid, formVersionId, published); FormVersionsInitializedVersionHash.Add(formVersionId); } private async Task ProcessSubmission(ApplicationFormDto applicationFormDto, JObject submissionData, int version) { ApplicationForm applicationForm = ObjectMapper.Map(applicationFormDto); - var result = await _intakeFormSubmissionManager.ProcessFormSubmissionAsync(applicationForm, submissionData); + var result = await IntakeFormSubmissionManager.ProcessFormSubmissionAsync(applicationForm, submissionData); AddFact("Synchronizing Data - Form Version: ", $"{version} Unity Application ID: {result}"); } @@ -199,7 +177,7 @@ private async Task ProcessSubmission(ApplicationFormDto applicationFormDto, JObj try { HashSet newChefsSubmissions = await GetChefsSubmissions(applicationFormDto, numberOfDaysToCheck); - HashSet existingSubmissions = GetSubmissionsByForm(applicationFormDto.Id); + HashSet existingSubmissions = await GetSubmissionsByFormAsync(applicationFormDto.Id); missingSubmissions = [.. newChefsSubmissions.Except(existingSubmissions)]; if (missingSubmissions.Count > 0) { @@ -245,7 +223,7 @@ private async Task ProcessSubmission(ApplicationFormDto applicationFormDto, JObj string activityTitle = "Review Missed Chefs Submissions " + tenantName; string activitySubtitle = "Environment: " + envInfo; - await _notificationsAppService.PostToNotificationsAsync(activityTitle, activitySubtitle, _facts); + await NotificationsAppService.PostToNotificationsAsync(activityTitle, activitySubtitle, _facts); } return (missingSubmissions ?? [], missingSubmissionsReportBuilder.ToString()); } @@ -253,31 +231,31 @@ private async Task ProcessSubmission(ApplicationFormDto applicationFormDto, JObj private async Task GetTenantNameAsync() { string tenantName = ""; - if (_currentTenant != null && !string.IsNullOrEmpty(_currentTenant.Name)) + if (CurrentTenant != null && !string.IsNullOrEmpty(CurrentTenant.Name)) { - tenantName = " -- Tenant: " + _currentTenant.Name; - } else if (_currentTenant != null && _currentTenant.Id != null) + tenantName = " -- Tenant: " + CurrentTenant.Name; + } else if (CurrentTenant != null && CurrentTenant.Id != null) { // Lookup the tenant name - Tenant? tenant = await _tenantRepository.FindAsync(_currentTenant.Id.Value); - tenantName = tenant != null ? " -- Tenant: " + tenant.Name : " -- Tenant: " + _currentTenant.Id; + Tenant? tenant = await TenantRepository.FindAsync(CurrentTenant.Id.Value); + tenantName = tenant != null ? " -- Tenant: " + tenant.Name : " -- Tenant: " + CurrentTenant.Id; } return tenantName; } - public HashSet GetSubmissionsByForm(Guid applicationFormId) + public async Task> GetSubmissionsByFormAsync(Guid applicationFormId) { - IQueryable queryableApplicationFormSubmissions = _applicationFormSubmissionRepository.GetQueryableAsync().Result; + IQueryable queryableApplicationFormSubmissions = await ApplicationFormSubmissionRepository.GetQueryableAsync(); var formSubmissionGuids = queryableApplicationFormSubmissions.Where(x => x.ApplicationFormId.Equals(applicationFormId)).Select(o => o.ChefsSubmissionGuid).ToHashSet(); return formSubmissionGuids; } public async Task> GetConnectedApplicationFormsAsync() { - IQueryable queryableApplicationForms = _applicationFormRepository.GetQueryableAsync().Result; + IQueryable queryableApplicationForms = await ApplicationFormRepository.GetQueryableAsync(); var forms = queryableApplicationForms.Where(x => (x.ApiKey ?? string.Empty) != string.Empty).ToList(); - return await Task.FromResult>(ObjectMapper.Map, List>([.. forms])); + return ObjectMapper.Map, List>([.. forms]); } public async Task> GetChefsSubmissions(ApplicationFormDto applicationFormDto, int numberOfDaysToCheck) @@ -306,35 +284,29 @@ public async Task> GetChefsSubmissions(ApplicationFormDto applic throw new ApiException(400, "Missing required parameter 'formId' when calling ListFormSubmissions"); } - string requestUrl = $"/forms/{applicationForm.ChefsApplicationFormGuid}/submissions"; + string chefsApi = await EndpointManagementAppService.GetChefsApiBaseUrlAsync(); + string requestUrl = $"{chefsApi}/forms/{applicationForm.ChefsApplicationFormGuid}/submissions"; if (!string.IsNullOrEmpty(queryString)) { requestUrl += queryString; } + requestUrl += (requestUrl.Contains('?') ? "&" : "?") + "deleted=false&filterformSubmissionStatusCode=true"; - var restRequest = new RestRequest(requestUrl, Method.Get) - { - Authenticator = new HttpBasicAuthenticator(applicationForm.ChefsApplicationFormGuid!, _stringEncryptionService.Decrypt(applicationForm.ApiKey!) ?? string.Empty) - }; + var decryptedApiKey = StringEncryptionService.Decrypt(applicationForm.ApiKey!) ?? string.Empty; + var response = await ResilientHttpRequest.HttpAsync( + HttpMethod.Get, + requestUrl, + basicAuth: (applicationForm.ChefsApplicationFormGuid!, decryptedApiKey)); - restRequest.AddParameter("deleted", "false"); - restRequest.AddParameter("filterformSubmissionStatusCode", "true"); + string content = await response.Content.ReadAsStringAsync(); - var response = await _intakeClient.GetAsync(restRequest); - string errorMessageBase = "Error calling ListFormSubmissions: "; - string errorMessage = (int)response.StatusCode switch + if (!response.IsSuccessStatusCode) { - >= 400 => errorMessageBase + response.Content, - 0 => errorMessageBase + response.ErrorMessage, - _ => "" - }; - - if (!string.IsNullOrEmpty(errorMessage)) - { - throw new ApiException((int)response.StatusCode, errorMessage, response.ErrorMessage ?? $"{response.StatusCode}"); + string errorMessage = "Error calling ListFormSubmissions: " + content; + throw new ApiException((int)response.StatusCode, errorMessage, response.ReasonPhrase ?? $"{response.StatusCode}"); } - List? jsonResponse = JsonSerializer.Deserialize>(response.Content ?? string.Empty, _submissionSerializerOptions); + List? jsonResponse = JsonSerializer.Deserialize>(content, _submissionSerializerOptions); return jsonResponse; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index d09c3bb30c..4bedeb5dd6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using System; using System.Linq; using System.Text.RegularExpressions; +using System.Text.Json; using System.Collections.Generic; using System.Threading.Tasks; using Unity.GrantManager.Applications; @@ -27,6 +29,7 @@ using Volo.Abp; using Volo.Abp.Uow; using Unity.GrantManager.Intakes.Mapping; +using Unity.Flex.Domain.Worksheets; namespace Unity.GrantManager.ApplicationForms { @@ -41,7 +44,9 @@ public class ApplicationFormVersionAppService( IFeatureChecker featureChecker, IApplicationFormVersionMappingReadService mappingReadService, IAICooldownService aiCooldownService, - IFormMappingService aiService) : + IFormMappingService aiService, + IWorksheetRepository worksheetRepository, + IRepository customFieldRepository) : CrudAppService< ApplicationFormVersion, ApplicationFormVersionDto, @@ -353,6 +358,174 @@ public virtual async Task GenerateMappingAsync(Guid i }; } + [HttpGet("api/app/application-form-version/pending-ai-worksheet")] + public virtual async Task GetPendingAiWorksheetAsync(Guid formVersionId) + { + await CheckPolicyAsync(AIPermissions.Analysis.ViewFormWorksheet); + + var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId); + return worksheet == null ? null : MapAiWorksheetReview(worksheet); + } + + [HttpPost("api/app/application-form-version/create-ai-worksheet-draft")] + public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, CreateAiWorksheetDraftDto input) + { + await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet); + + var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId); + if (worksheet == null || worksheet.Id != input.SessionId) + { + throw new UserFriendlyException("The AI worksheet is no longer available for review."); + } + + var title = input.Title?.Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + throw new UserFriendlyException("A worksheet title is required."); + } + + var selectedFieldIds = input.SelectedFieldIds?.ToHashSet() ?? []; + if (selectedFieldIds.Count == 0) + { + throw new UserFriendlyException("Select at least one suggested field."); + } + + var fields = worksheet.Sections.SelectMany(section => section.Fields).ToList(); + var unknownFieldIds = selectedFieldIds.Except(fields.Select(field => field.Id)).ToList(); + if (unknownFieldIds.Count > 0) + { + throw new UserFriendlyException("The AI worksheet selection is invalid."); + } + + var draftName = await GetNextAiWorksheetDraftNameAsync(title); + var draft = new Worksheet(Guid.NewGuid(), draftName, title); + + var draftSection = new WorksheetSection(Guid.NewGuid(), "Suggested Fields") + { + Worksheet = draft + }.SetOrder(1); + draft.AddSection(draftSection); + + foreach (var (field, index) in fields + .Where(field => selectedFieldIds.Contains(field.Id)) + .OrderBy(field => field.Section.Order) + .ThenBy(field => field.Order) + .Select((field, index) => (field, index))) + { + var draftField = new CustomField( + Guid.NewGuid(), + field.Key, + draft.Name, + field.Label, + field.Type, + NormalizeCustomFieldDefinition(field.Definition)); + draftField.Section = draftSection; + draftSection.AddField(draftField); + draftField.SetOrder((uint)(index + 1)).SetEnabled(true); + } + + await worksheetRepository.InsertAsync(draft, true); + + foreach (var field in fields.Where(field => selectedFieldIds.Contains(field.Id))) + { + field.Section.RemoveField(field); + await customFieldRepository.DeleteAsync(field.Id); + } + + if (worksheet.Sections.All(section => section.Fields.Count == 0)) + { + await worksheetRepository.DeleteAsync(worksheet, true); + return; + } + + await worksheetRepository.UpdateAsync(worksheet, true); + } + + [HttpPost("api/app/application-form-version/discard-ai-worksheet-suggestions")] + public virtual async Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId) + { + await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet); + + var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId); + if (worksheet != null) + { + await worksheetRepository.DeleteAsync(worksheet, true); + } + } + + private async Task GetPendingAiWorksheetEntityAsync(Guid formVersionId) + { + var formVersion = await formVersionRepository.GetAsync(formVersionId); + var worksheet = await worksheetRepository.GetByNameAsync( + AiWorksheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true); + + if (worksheet?.Published == false) + { + return worksheet; + } + + return null; + } + + private static AiWorksheetReviewDto MapAiWorksheetReview(Worksheet worksheet) => new() + { + SessionId = worksheet.Id, + Fields = worksheet.Sections + .OrderBy(section => section.Order) + .SelectMany(section => section.Fields.OrderBy(field => field.Order)) + .Select(field => new AiWorksheetReviewFieldDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Type = field.Type.ToString(), + Selected = true + }) + .ToList() + }; + + private async Task GetNextAiWorksheetDraftNameAsync(string title) + { + var titlePart = Regex.Replace(title.Trim().ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-'); + var baseName = $"ai-{(string.IsNullOrEmpty(titlePart) ? "worksheet" : titlePart)}"; + var candidate = baseName; + var suffix = 2; + + while (await worksheetRepository.GetByNameAsync(candidate, false) != null) + { + candidate = $"{baseName}-{suffix++}"; + } + + return candidate; + } + + private static string NormalizeCustomFieldDefinition(string definition) + { + try + { + using var document = JsonDocument.Parse(definition); + if (document.RootElement.ValueKind != JsonValueKind.String) + { + return definition; + } + + var unwrappedDefinition = document.RootElement.GetString(); + if (string.IsNullOrWhiteSpace(unwrappedDefinition)) + { + return definition; + } + + using var unwrappedDocument = JsonDocument.Parse(unwrappedDefinition); + return unwrappedDocument.RootElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array + ? unwrappedDefinition + : definition; + } + catch (JsonException) + { + return definition; + } + } + private async Task GetVersion(Guid formVersionId) { var formVersion = await formVersionRepository.GetByChefsFormVersionAsync(formVersionId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs index ae1a206917..4a46d2b10f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs @@ -262,7 +262,7 @@ public List GetAllActions() public async Task> GetPermittedActions(Guid assessmentId) { var assessment = await _assessmentRepository.GetAsync(assessmentId); - var workflowActions = assessment.Workflow.GetPermittedActions(); + var workflowActions = await assessment.Workflow.GetPermittedActionsAsync(); List permittedActions = new(); foreach (var triggerAction in workflowActions) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs index c744885522..1a4c35b0b1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs @@ -3,15 +3,16 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.Primitives; using System; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Volo.Abp.Authorization; using Volo.Abp.BlobStoring; using Volo.Abp.DependencyInjection; +using Volo.Abp.Users; using Volo.Abp.Validation; namespace Unity.GrantManager.Attachments; @@ -23,15 +24,17 @@ public partial class S3BlobProvider : BlobProviderBase, ITransientDependency private readonly IAssessmentAttachmentRepository _assessmentAttachmentRepository; private readonly IApplicantAttachmentRepository _applicantAttachmentRepository; private readonly IAmazonS3 _amazonS3Client; + private readonly ICurrentUser _currentUser; - public S3BlobProvider(IHttpContextAccessor httpContextAccessor, IApplicationAttachmentRepository attachmentRepository, IAssessmentAttachmentRepository assessmentAttachmentRepository, IApplicantAttachmentRepository applicantAttachmentRepository, IAmazonS3 amazonS3Client) + public S3BlobProvider(IHttpContextAccessor httpContextAccessor, IApplicationAttachmentRepository attachmentRepository, IAssessmentAttachmentRepository assessmentAttachmentRepository, IApplicantAttachmentRepository applicantAttachmentRepository, IAmazonS3 amazonS3Client, ICurrentUser currentUser) { _httpContextAccessor = httpContextAccessor; _applicationAttachmentRepository = attachmentRepository; _assessmentAttachmentRepository = assessmentAttachmentRepository; _applicantAttachmentRepository = applicantAttachmentRepository; _amazonS3Client = amazonS3Client; - } + _currentUser = currentUser; + } public override async Task DeleteAsync(BlobProviderDeleteArgs args) { @@ -156,39 +159,39 @@ private static string GetMimeType(string fileName) public override async Task SaveAsync(BlobProviderSaveArgs args) { var httpContext = _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No active HttpContext."); - var queryParams = httpContext.Request?.Query ?? throw new InvalidOperationException("No query parameters in the current request."); - var routeData = _httpContextAccessor.HttpContext.GetRouteData(); - + var routeData = httpContext.GetRouteData(); + var assessmentId = routeData.Values["assessmentId"]; var applicationId = routeData.Values["applicationId"]; var applicantId = routeData.Values["applicantId"]; - queryParams.TryGetValue("userId", out StringValues currentUserId); + + // The uploader must be the authenticated caller, not a client-supplied value - a userId + // taken from the request (query string, form field, etc.) can be set to any GUID by the + // caller, letting one user attribute an upload to another user's identity. A missing + // ICurrentUser.Id means this was reached without authentication (AttachmentController + // requires [Authorize], so this should never happen) - fail loudly rather than silently + // attributing the upload to Guid.Empty, which would look like a valid, specific user. + var currentUserId = _currentUser.Id ?? throw new AbpAuthorizationException("Cannot save an attachment without an authenticated user."); + if (assessmentId != null) - { - - #pragma warning disable CS8604 // Possible null reference argument. - await UploadAssessmentAttachment(args, assessmentId.ToString(), currentUserId.ToString()); - #pragma warning restore CS8604 // Possible null reference argument. + { + await UploadAssessmentAttachment(args, assessmentId.ToString()!, currentUserId); } else if(applicationId != null) - { - #pragma warning disable CS8604 // Possible null reference argument. - await UploadApplicationAttachment(args, applicationId.ToString(), currentUserId.ToString()); - #pragma warning restore CS8604 // Possible null reference argument. + { + await UploadApplicationAttachment(args, applicationId.ToString()!, currentUserId); } else if (applicantId != null) { - #pragma warning disable CS8604 // Possible null reference argument. - await UploadApplicantAttachment(args, applicantId.ToString(), currentUserId.ToString()); - #pragma warning restore CS8604 // Possible null reference argument. + await UploadApplicantAttachment(args, applicantId.ToString()!, currentUserId); } else { throw new AbpValidationException("Missing parameter: applicationId/assessmentId/applicantId"); - } - } - - private async Task UploadAssessmentAttachment(BlobProviderSaveArgs args, string assessmentId, string currentUserId) + } + } + + private async Task UploadAssessmentAttachment(BlobProviderSaveArgs args, string assessmentId, Guid currentUserId) { var config = args.Configuration.GetS3BlobProviderConfiguration(); var bucket = config.Bucket; @@ -211,21 +214,21 @@ await _assessmentAttachmentRepository.InsertAsync( { AssessmentId = new Guid(assessmentId), S3ObjectKey = key, - UserId = new Guid(currentUserId), + UserId = currentUserId, FileName = args.BlobName, Time = DateTime.UtcNow, }); } else { - attachment.UserId = new Guid(currentUserId); + attachment.UserId = currentUserId; attachment.FileName = args.BlobName; attachment.Time = DateTime.UtcNow; await _assessmentAttachmentRepository.UpdateAsync(attachment); } } - private async Task UploadApplicationAttachment(BlobProviderSaveArgs args, string applicationId, string currentUserId) + private async Task UploadApplicationAttachment(BlobProviderSaveArgs args, string applicationId, Guid currentUserId) { var config = args.Configuration.GetS3BlobProviderConfiguration(); var bucket = config.Bucket; @@ -248,21 +251,21 @@ await _applicationAttachmentRepository.InsertAsync( { ApplicationId = new Guid(applicationId), S3ObjectKey = key, - UserId = new Guid(currentUserId), - FileName = args.BlobName, + UserId = currentUserId, + FileName = args.BlobName, Time = DateTime.UtcNow, }); } else { - attachment.UserId = new Guid(currentUserId); - attachment.FileName = args.BlobName; + attachment.UserId = currentUserId; + attachment.FileName = args.BlobName; attachment.Time = DateTime.UtcNow; await _applicationAttachmentRepository.UpdateAsync(attachment); } } - private async Task UploadApplicantAttachment(BlobProviderSaveArgs args, string applicantId, string currentUserId) + private async Task UploadApplicantAttachment(BlobProviderSaveArgs args, string applicantId, Guid currentUserId) { var config = args.Configuration.GetS3BlobProviderConfiguration(); var bucket = config.Bucket; @@ -285,14 +288,14 @@ await _applicantAttachmentRepository.InsertAsync( { ApplicantId = new Guid(applicantId), S3ObjectKey = key, - UserId = new Guid(currentUserId), + UserId = currentUserId, FileName = args.BlobName, Time = DateTime.UtcNow, }); } else { - attachment.UserId = new Guid(currentUserId); + attachment.UserId = currentUserId; attachment.FileName = args.BlobName; attachment.Time = DateTime.UtcNow; await _applicantAttachmentRepository.UpdateAsync(attachment); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ConfigureIntakeClientOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ConfigureIntakeClientOptions.cs deleted file mode 100644 index ddaecd4023..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ConfigureIntakeClientOptions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Unity.GrantManager.Intake; -using Unity.GrantManager.Integrations; -using System.Threading; -using System.Threading.Tasks; - -namespace Unity.GrantManager; - -public class ConfigureIntakeClientOptions( - IConfiguration configuration, - IEndpointManagementAppService endpointManagementAppService) -{ - public async Task ConfigureAsync(IntakeClientOptions options, CancellationToken cancellationToken = default) - { - var intakeBaseUri = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.INTAKE_API_BASE); - options.BaseUri = intakeBaseUri; - options.BearerTokenPlaceholder = configuration["Intake:BearerTokenPlaceholder"] ?? ""; - options.UseBearerToken = configuration.GetValue("Intake:UseBearerToken"); - options.AllowUnregisteredVersions = configuration.GetValue("Intake:AllowUnregisteredVersions"); - } -} - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs index 50b105898a..62f17de29c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs @@ -8,14 +8,13 @@ using Unity.AI.Cooldown; using Unity.AI.Operations; using Unity.AI.Requests; +using Unity.AI.Responses; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Applications; -using Unity.GrantManager.Flex; -using Unity.Flex.Domain.WorksheetLinks; using Unity.Flex.Domain.Worksheets; using Unity.Flex.Worksheets; -using Unity.Modules.Shared.Correlation; +using Unity.Flex.Worksheets.Definitions; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -28,7 +27,6 @@ public class GenerateFormWorksheetJob( IApplicationFormVersionRepository applicationFormVersionRepository, IApplicationFormRepository applicationFormRepository, IWorksheetRepository worksheetRepository, - IWorksheetLinkRepository worksheetLinkRepository, IApplicationFormVersionMappingReadService mappingReadService, IFormWorksheetService aiService, IRepository generationRequestRepository, @@ -64,76 +62,67 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( { var formVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); - var worksheetName = BuildWorksheetName(formVersion.Id, applicationForm.Id); + var worksheetName = AiWorksheetSuggestionName.Build(applicationForm.Id, formVersion.Id); var existingWorksheet = await worksheetRepository.GetByNameAsync(worksheetName, true); - var mappingReadModel = await mappingReadService.GetAsync(formVersion.Id); - - List worksheetSnapshots = []; if (existingWorksheet != null) { - worksheetSnapshots.Add(existingWorksheet); + if (existingWorksheet.Published) + { + logger.LogWarning( + "A published worksheet already uses AI suggestion name {WorksheetName}; leaving it unchanged.", + worksheetName); + } + else + { + logger.LogInformation( + "An AI suggestion worksheet is pending review for form version {FormVersionId}; leaving it unchanged.", + formVersion.Id); + } } - var promptData = new + else { - applicationFormVersionId = formVersion.Id, - chefsFormVersionGuid = formVersion.ChefsFormVersionGuid, - applicationFormId = applicationForm.Id, - formName = applicationForm.ApplicationFormName, - scoresheetId = applicationForm.ScoresheetId, - chefsFields = mappingReadModel.ChefsFields, - unityCoreFields = mappingReadModel.UnityCoreFields, - existingMapping = formVersion.SubmissionHeaderMapping, - formSchema = formVersion.FormSchema, - existingWorksheets = worksheetSnapshots.Select(worksheet => new + var mappingReadModel = await mappingReadService.GetAsync(formVersion.Id); + var promptData = new { - worksheet.Id, - worksheet.Name, - worksheet.Title, - worksheet.Version, - worksheet.Published, - worksheet.ReportViewName, - sections = worksheet.Sections.Select(section => new - { - section.Name, - section.Order, - fields = section.Fields.Select(field => new + applicationFormVersionId = formVersion.Id, + chefsFormVersionGuid = formVersion.ChefsFormVersionGuid, + applicationFormId = applicationForm.Id, + formName = applicationForm.ApplicationFormName, + scoresheetId = applicationForm.ScoresheetId, + chefsFields = mappingReadModel.ChefsFields, + unityCoreFields = mappingReadModel.UnityCoreFields, + existingMapping = formVersion.SubmissionHeaderMapping, + formSchema = formVersion.FormSchema, + existingCustomFields = mappingReadModel.Worksheets + .SelectMany(worksheet => worksheet.Fields.Select(field => new { + worksheetId = worksheet.WorksheetId, + worksheetName = worksheet.WorksheetName, field.Name, - field.Key, field.Label, - field.Type, - field.Order, - field.Enabled, - field.Definition - }) - }) - }) - }; + field.Type + })) + }; - var worksheetResponse = await aiService.GenerateFormWorksheetAsync(new FormWorksheetRequest - { - Data = JsonSerializer.SerializeToElement(promptData), - PromptVersion = args.PromptVersion - }); + var worksheetResponse = await aiService.GenerateFormWorksheetAsync(new FormWorksheetRequest + { + Data = JsonSerializer.SerializeToElement(promptData), + PromptVersion = args.PromptVersion + }); - var worksheetJson = worksheetResponse.Worksheet; - var createDto = ParseWorksheetDefinition(worksheetJson); - var worksheet = existingWorksheet == null - ? BuildWorksheet(createDto, worksheetName) - : RebuildWorksheet(existingWorksheet, createDto); - worksheet.SetPublished(true); - if (existingWorksheet == null) - { + var suggestions = ParseWorksheetDefinition(worksheetResponse.Worksheet); + var worksheet = BuildWorksheet(suggestions, worksheetName); + worksheet.SetPublished(false); await worksheetRepository.InsertAsync(worksheet); - } - else - { - await worksheetRepository.UpdateAsync(worksheet); - } - await UpsertWorksheetLinkAsync(worksheet.Id, formVersion.Id); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync( + aiCooldownService, + logger, + args.RequestedByUserId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormWorksheetOperationType); + } - await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormWorksheetOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, @@ -155,112 +144,99 @@ await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( } } - internal static CreateWorksheetDto ParseWorksheetDefinition(string json) + internal static List ParseWorksheetDefinition(string json) { if (string.IsNullOrWhiteSpace(json)) { throw new InvalidOperationException("Worksheet generation returned empty content."); } - var dto = JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); - - if (dto == null || string.IsNullOrWhiteSpace(dto.Title) || dto.Sections is not { Count: > 0 }) + AiWorksheetSuggestions? dto; + try + { + dto = JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); + } + catch (JsonException) { throw new InvalidOperationException("Worksheet generation returned an unusable worksheet definition."); } - return dto; - } - - private static string BuildWorksheetName(Guid formVersionId, Guid formId) - { - return $"ai-form-{formId}-version-{formVersionId}-worksheet"; - } - - private static Worksheet BuildWorksheet(CreateWorksheetDto dto, string worksheetName) - { - var worksheet = new Worksheet(Guid.NewGuid(), worksheetName, dto.Title) + if (dto?.Fields == null) { - ReportColumns = dto.ReportColumns, - ReportKeys = dto.ReportKeys, - ReportViewName = dto.ReportViewName - }; - - worksheet.SetVersion(dto.Version); - worksheet.SetPublished(dto.Published); + throw new InvalidOperationException("Worksheet generation returned an unusable worksheet definition."); + } - foreach (var section in dto.Sections.OrderBy(s => s.Order)) + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var field in dto.Fields) { - var worksheetSection = new WorksheetSection(Guid.NewGuid(), section.Name).SetOrder(section.Order); - worksheetSection.Worksheet = worksheet; - worksheet.AddSection(worksheetSection); - - foreach (var field in section.Fields) + field.Key = field.Key?.Trim() ?? string.Empty; + field.Label = field.Label?.Trim() ?? string.Empty; + field.Type = field.Type?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(field.Key) + || string.IsNullOrWhiteSpace(field.Label) + || !keys.Add(field.Key) + || field.Type.Any(char.IsDigit) + || !Enum.TryParse(field.Type, true, out var type) + || !SupportedSuggestionTypes.Contains(type)) { - var customField = new CustomField( - Guid.NewGuid(), - field.Key, - worksheet.Name, - field.Label, - field.Type, - field.Definition); - customField.Section = worksheetSection; - worksheetSection.AddField(customField); + throw new InvalidOperationException("Worksheet generation returned an unusable worksheet definition."); } + + field.ResolvedType = type; } - return worksheet; + return dto.Fields; } - private static Worksheet RebuildWorksheet(Worksheet worksheet, CreateWorksheetDto dto) + internal static Worksheet BuildWorksheet(List suggestions, string worksheetName) { - worksheet.SetName(worksheet.Name); - worksheet.SetTitle(dto.Title); - worksheet.SetVersion(dto.Version); - worksheet.SetPublished(dto.Published); - worksheet.SetReportingFields(dto.ReportKeys, dto.ReportColumns, dto.ReportViewName); + var worksheet = new Worksheet(Guid.NewGuid(), worksheetName, "AI Suggested Fields"); + RebuildWorksheet(worksheet, suggestions); + return worksheet; + } + private static void RebuildWorksheet(Worksheet worksheet, List suggestions) + { worksheet.Sections.Clear(); - foreach (var section in dto.Sections.OrderBy(s => s.Order)) - { - var worksheetSection = new WorksheetSection(Guid.NewGuid(), section.Name).SetOrder(section.Order); - worksheetSection.Worksheet = worksheet; - worksheet.AddSection(worksheetSection); + var section = new WorksheetSection(Guid.NewGuid(), "Suggested Fields").SetOrder(1); + section.Worksheet = worksheet; + worksheet.AddSection(section); - foreach (var field in section.Fields) - { - var customField = new CustomField( - Guid.NewGuid(), - field.Key, - worksheet.Name, - field.Label, - field.Type, - field.Definition); - customField.Section = worksheetSection; - worksheetSection.AddField(customField); - } + foreach (var (field, index) in suggestions.Select((field, index) => (field, index))) + { + var customField = new CustomField( + Guid.NewGuid(), + field.Key, + worksheet.Name, + field.Label, + field.ResolvedType, + DefinitionResolver.Resolve(field.ResolvedType, null)); + customField.Section = section; + section.AddField(customField); + customField.SetOrder((uint)(index + 1)); } - - return worksheet; } - private async Task UpsertWorksheetLinkAsync(Guid worksheetId, Guid correlationId) + private static readonly HashSet SupportedSuggestionTypes = + [ + CustomFieldType.Text, CustomFieldType.TextArea, CustomFieldType.Numeric, + CustomFieldType.Currency, CustomFieldType.Date, CustomFieldType.DateTime, + CustomFieldType.Email, CustomFieldType.Phone, CustomFieldType.YesNo, + CustomFieldType.Checkbox + ]; + + private sealed class AiWorksheetSuggestions { - var existingLink = await worksheetLinkRepository.GetExistingLinkAsync(worksheetId, correlationId, CorrelationConsts.FormVersion); - if (existingLink != null) - { - existingLink.SetAnchor(FlexConsts.CustomTab).SetOrder(1); - await worksheetLinkRepository.UpdateAsync(existingLink); - return; - } + public List? Fields { get; set; } + } - await worksheetLinkRepository.InsertAsync(new WorksheetLink( - Guid.NewGuid(), - worksheetId, - correlationId, - CorrelationConsts.FormVersion, - FlexConsts.CustomTab, - 1)); + internal sealed class AiWorksheetFieldSuggestion + { + public string? Key { get; set; } + public string? Label { get; set; } + public string? Type { get; set; } + public CustomFieldType ResolvedType { get; set; } } + } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index d131c10dac..03a3710eaa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -3,14 +3,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using RestSharp; -using RestSharp.Serializers.Json; using System.Text.Json; using System.Text.Json.Serialization; using Unity.GrantManager.Assessments; using Unity.GrantManager.Attachments; using Unity.GrantManager.Events; -using Unity.GrantManager.Intake; using Unity.GrantManager.Integrations.Css; using Unity.TenantManagement; using Volo.Abp.Mapperly; @@ -198,34 +195,6 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.AddScoped(); - context.Services.AddSingleton(provider => - { - var options = (provider.GetService>()?.Value) ?? throw new InvalidOperationException("IntakeClientOptions not configured."); - if (options.BaseUri == string.Empty) - { - options.BaseUri = "https://submit.digital.gov.bc.ca/app/api/v1"; - } - - var restOptions = options != null - ? new RestClientOptions(options.BaseUri) - { - FailOnDeserializationError = true, - ThrowOnDeserializationError = true - } - : new RestClientOptions(); - - return new RestClient( - restOptions, - configureSerialization: s => s.UseSystemTextJson(new JsonSerializerOptions - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }) - ); - }); - // Max paging limits ExtensibleLimitedResultRequestDto.DefaultMaxResultCount = int.MaxValue; ExtensibleLimitedResultRequestDto.MaxMaxResultCount = int.MaxValue; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs index c3aeda6258..e23a49f2ef 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs @@ -1,5 +1,6 @@ using System; using System.Text; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; @@ -21,7 +22,7 @@ public class GrantsPortalAcknowledgmentPublisher( NullValueHandling = NullValueHandling.Ignore }; - public void Publish(IModel channel, string originalMessageId, string correlationId, string status, string details) + public async Task PublishAsync(IChannel channel, string originalMessageId, string correlationId, string status, string details) { var ack = new MessageAcknowledgment { @@ -37,18 +38,21 @@ public void Publish(IModel channel, string originalMessageId, string correlation var json = JsonConvert.SerializeObject(ack, s_jsonSettings); var body = Encoding.UTF8.GetBytes(json); - var properties = channel.CreateBasicProperties(); - properties.Type = "MessageAcknowledgment"; - properties.ContentType = "application/json"; - properties.ContentEncoding = "utf-8"; - properties.Persistent = true; - properties.MessageId = ack.MessageId; - properties.CorrelationId = correlationId; - properties.Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + var properties = new BasicProperties + { + Type = "MessageAcknowledgment", + ContentType = "application/json", + ContentEncoding = "utf-8", + Persistent = true, + MessageId = ack.MessageId, + CorrelationId = correlationId, + Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()) + }; - channel.BasicPublish( + await channel.BasicPublishAsync( exchange: _options.Exchange, routingKey: _options.AckRoutingKey, + mandatory: false, basicProperties: properties, body: body); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs index c4c9ed0f6a..db277e9d91 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -23,7 +23,7 @@ namespace Unity.GrantManager.GrantsPortal; /// public class GrantsPortalCommandConsumerService( IServiceProvider serviceProvider, - IAsyncConnectionFactory connectionFactory, + IConnectionFactory connectionFactory, IOptions options, ILogger logger) : BackgroundService { @@ -38,7 +38,7 @@ public class GrantsPortalCommandConsumerService( private readonly SemaphoreSlim _reconnectLock = new(1, 1); private IConnection? _connection; - private IModel? _channel; + private IChannel? _channel; private const int MaxRetries = 5; private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); @@ -71,13 +71,13 @@ private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) { logger.LogInformation("Connecting to RabbitMQ for Grants Portal consumer (attempt {Attempt}/{MaxRetries})", attempt, MaxRetries); - _connection = connectionFactory.CreateConnection(); - _connection.ConnectionShutdown += OnConnectionShutdown; - _channel = _connection.CreateModel(); - _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + _connection = await connectionFactory.CreateConnectionAsync(cancellationToken); + _connection.ConnectionShutdownAsync += OnConnectionShutdownAsync; + _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + await _channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken); - DeclareTopology(); - StartConsuming(); + await DeclareTopologyAsync(cancellationToken); + await StartConsumingAsync(cancellationToken); logger.LogInformation("Grants Portal command consumer started. Listening on queue {Queue}", _options.InboundQueue); return; @@ -111,15 +111,15 @@ private async Task SlowReconnectLoopAsync(CancellationToken cancellationToken) try { logger.LogInformation("Slow reconnect: attempting to connect to RabbitMQ..."); - CleanupConnection(); + await CleanupConnectionAsync(); - _connection = connectionFactory.CreateConnection(); - _connection.ConnectionShutdown += OnConnectionShutdown; - _channel = _connection.CreateModel(); - _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + _connection = await connectionFactory.CreateConnectionAsync(cancellationToken); + _connection.ConnectionShutdownAsync += OnConnectionShutdownAsync; + _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + await _channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken); - DeclareTopology(); - StartConsuming(); + await DeclareTopologyAsync(cancellationToken); + await StartConsumingAsync(cancellationToken); logger.LogInformation("Slow reconnect: successfully reconnected. Listening on queue {Queue}", _options.InboundQueue); return; @@ -135,9 +135,9 @@ private async Task SlowReconnectLoopAsync(CancellationToken cancellationToken) } } - private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) + private Task OnConnectionShutdownAsync(object sender, ShutdownEventArgs e) { - if (_stoppingToken.IsCancellationRequested) return; + if (_stoppingToken.IsCancellationRequested) return Task.CompletedTask; logger.LogWarning("RabbitMQ connection lost: {Reason}. Attempting to reconnect...", e.ReplyText); @@ -152,7 +152,7 @@ private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) try { await Task.Delay(InitialRetryDelay, _stoppingToken); - CleanupConnection(); + await CleanupConnectionAsync(); await ConnectAndConsumeAsync(_stoppingToken); } catch (OperationCanceledException) when (_stoppingToken.IsCancellationRequested) @@ -168,34 +168,41 @@ private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) _reconnectLock.Release(); } }, _stoppingToken); + + return Task.CompletedTask; } - private void DeclareTopology() + private async Task DeclareTopologyAsync(CancellationToken cancellationToken) { if (_channel == null) return; - _channel.ExchangeDeclare( + await _channel.ExchangeDeclareAsync( exchange: _options.Exchange, type: _options.ExchangeType, durable: true, - autoDelete: false); + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); - _channel.QueueDeclare( + await _channel.QueueDeclareAsync( queue: _options.InboundQueue, durable: true, exclusive: false, autoDelete: false, - arguments: new System.Collections.Generic.Dictionary + arguments: new System.Collections.Generic.Dictionary { { "x-queue-type", "quorum" } - }); + }, + cancellationToken: cancellationToken); foreach (var routingKey in _options.InboundRoutingKeys) { - _channel.QueueBind( + await _channel.QueueBindAsync( queue: _options.InboundQueue, exchange: _options.Exchange, - routingKey: routingKey); + routingKey: routingKey, + arguments: null, + cancellationToken: cancellationToken); } logger.LogInformation( @@ -203,17 +210,22 @@ private void DeclareTopology() _options.Exchange, _options.InboundQueue, string.Join(", ", _options.InboundRoutingKeys)); } - private void StartConsuming() + private async Task StartConsumingAsync(CancellationToken cancellationToken) { if (_channel == null) return; var consumer = new AsyncEventingBasicConsumer(_channel); - consumer.Received += OnMessageReceivedAsync; + consumer.ReceivedAsync += OnMessageReceivedAsync; - _channel.BasicConsume( + await _channel.BasicConsumeAsync( queue: _options.InboundQueue, autoAck: false, - consumer: consumer); + consumerTag: string.Empty, + noLocal: false, + exclusive: false, + arguments: null, + consumer: consumer, + cancellationToken: cancellationToken); } /// @@ -225,7 +237,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e var messageId = ea.BasicProperties?.MessageId ?? string.Empty; var messageType = ea.BasicProperties?.Type ?? string.Empty; var correlationId = ea.BasicProperties?.CorrelationId ?? string.Empty; - var consumingChannel = ((AsyncEventingBasicConsumer)sender).Model; + var consumingChannel = ((AsyncEventingBasicConsumer)sender).Channel; logger.LogInformation("Received message {MessageId} type={MessageType}", messageId, messageType); @@ -233,7 +245,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e if (string.Equals(messageType, "MessageAcknowledgment", StringComparison.OrdinalIgnoreCase)) { logger.LogDebug("Discarding acknowledgment message {MessageId} to prevent loop", messageId); - consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); return; } @@ -245,7 +257,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e if (envelope == null) { logger.LogError("Failed to deserialize message {MessageId}. Discarding.", messageId); - consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); return; } @@ -257,7 +269,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e if (string.IsNullOrWhiteSpace(messageId)) { logger.LogError("Received message with missing/blank MessageId. Discarding. CorrelationId={CorrelationId}", correlationId); - consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); return; } @@ -277,7 +289,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e if (existing != null) { logger.LogInformation("Message {MessageId} already in inbox (status={Status}). Skipping.", messageId, existing.Status); - consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); return; } @@ -307,12 +319,12 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e catch (Exception ex) { logger.LogError(ex, "Error saving message {MessageId} to inbox. Message will be requeued.", messageId); - consumingChannel.BasicReject(ea.DeliveryTag, requeue: true); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: true); return; } // ACK only after successful save to inbox - consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); } private static Guid? ResolveTenantId(string? provider) @@ -326,15 +338,13 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e return null; } - private void CleanupConnection() + private async Task CleanupConnectionAsync() { try { - if (_connection != null) _connection.ConnectionShutdown -= OnConnectionShutdown; - _channel?.Close(); - _channel?.Dispose(); - _connection?.Close(); - _connection?.Dispose(); + _connection?.ConnectionShutdownAsync -= OnConnectionShutdownAsync; + if (_channel != null) await _channel.DisposeAsync(); + if (_connection != null) await _connection.DisposeAsync(); } catch (Exception ex) { @@ -369,7 +379,19 @@ private static bool IsDuplicateKeyException(Exception ex) public override void Dispose() { - CleanupConnection(); + try + { + _connection?.ConnectionShutdownAsync -= OnConnectionShutdownAsync; + _channel?.Dispose(); + _connection?.Dispose(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error during connection cleanup on dispose"); + } + + _channel = null; + _connection = null; _reconnectLock.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs index a6de10b2be..8546ecdea0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs @@ -17,15 +17,15 @@ namespace Unity.GrantManager.GrantsPortal; /// public class GrantsPortalOutboxWorker : OutboxWorkerBase { - private readonly IAsyncConnectionFactory _connectionFactory; + private readonly IConnectionFactory _connectionFactory; private IConnection? _connection; - private IModel? _channel; + private IChannel? _channel; protected override string SourceName => GrantsPortalRabbitMqOptions.SourceName; public GrantsPortalOutboxWorker( IServiceProvider serviceProvider, - IAsyncConnectionFactory connectionFactory, + IConnectionFactory connectionFactory, IOptions options) : base(serviceProvider) { @@ -46,11 +46,6 @@ public GrantsPortalOutboxWorker( .Build(); } - protected override void OnBeforePublishCycle() - { - EnsureChannel(); - } - protected override void OnPublishCycleError(Exception ex) { CleanupChannel(); @@ -58,33 +53,29 @@ protected override void OnPublishCycleError(Exception ex) protected override async Task PublishMessageAsync(IServiceScope scope, OutboxMessage outboxMsg) { + await EnsureChannelAsync(); + var publisher = scope.ServiceProvider.GetRequiredService(); - publisher.Publish( + // Publisher confirmations are enabled on the channel, so PublishAsync awaits the + // broker confirmation and throws if the ack message is not confirmed. + await publisher.PublishAsync( _channel!, outboxMsg.OriginalMessageId, outboxMsg.CorrelationId, outboxMsg.AckStatus, outboxMsg.Details); - - // Wait for broker to confirm - if (!_channel!.WaitForConfirms(TimeSpan.FromSeconds(5))) - { - throw new InvalidOperationException("Broker did not confirm ack publish"); - } - - await Task.CompletedTask; } - private void EnsureChannel() + private async Task EnsureChannelAsync() { if (_channel is { IsOpen: true }) return; CleanupChannel(); - _connection = _connectionFactory.CreateConnection(); - _channel = _connection.CreateModel(); - _channel.ConfirmSelect(); + _connection = await _connectionFactory.CreateConnectionAsync(); + _channel = await _connection.CreateChannelAsync( + new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true)); Logger.LogInformation("Outbox worker RabbitMQ channel established"); } @@ -93,9 +84,7 @@ private void CleanupChannel() { try { - _channel?.Close(); _channel?.Dispose(); - _connection?.Close(); _connection?.Dispose(); } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeClientOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeClientOptions.cs deleted file mode 100644 index 7ccf6695b3..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeClientOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Unity.GrantManager.Intake; - -public class IntakeClientOptions -{ - public string BaseUri { get; set; } = string.Empty; - - public string FormId { get; set; } = string.Empty; - - public string ApiKey { get; set; } = string.Empty; - - public string BearerTokenPlaceholder { get; set; } = string.Empty; - - public bool UseBearerToken { get; set; } - - public bool AllowUnregisteredVersions { get; set; } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj index b5a7b1a9ca..9c3ca77020 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj @@ -33,33 +33,30 @@ - - - - + + + + - + - - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj index 8ade8482cb..11ae6d5466 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj @@ -1,4 +1,4 @@ - + @@ -17,24 +17,22 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - - - - + + - + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj index dea88fd8ef..3647cb4840 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj @@ -1,37 +1,37 @@ - - - - - - net10.0 - enable - Unity.GrantManager - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + net10.0 + enable + Unity.GrantManager + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflow.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflow.cs index 8db065c27b..0034ad6a26 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflow.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflow.cs @@ -1,5 +1,6 @@ using Stateless; using System; +using System.Linq; using System.Threading.Tasks; using Volo.Abp; @@ -40,7 +41,7 @@ public virtual TStates GetState() public virtual async Task ExecuteActionAsync(TTriggers action) { - if (_stateMachine.CanFire(action)) + if ((await _stateMachine.GetPermittedTriggersAsync()).Contains(action)) { await _stateMachine.FireAsync(action); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflowExtensions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflowExtensions.cs index f6572df6f6..d4f251af91 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflowExtensions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Workflow/UnityWorkflowExtensions.cs @@ -1,6 +1,7 @@ using Stateless.Graph; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; namespace Unity.GrantManager.Workflow; public static class UnityWorkflowExtensions @@ -8,9 +9,9 @@ public static class UnityWorkflowExtensions /// /// The currently permitted actions allowed by the workflow state machine. /// - public static IEnumerable GetPermittedActions(this UnityWorkflow workflow) + public static async Task> GetPermittedActionsAsync(this UnityWorkflow workflow) { - return workflow._stateMachine.GetPermittedTriggers(); + return await workflow._stateMachine.GetPermittedTriggersAsync(); } /// diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs index 1c92533519..dda32a25f5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs @@ -135,7 +135,7 @@ public void ConfigureWorkflow(StateMachine HasPermission(GrantApplicationPermissions.Approvals.DeferAfterApproval)) + .PermitIfAsync(GrantApplicationAction.Defer, GrantApplicationState.DEFER, () => HasPermissionAsync(GrantApplicationPermissions.Approvals.DeferAfterApproval)) .PermitIf(GrantApplicationAction.Deny, GrantApplicationState.GRANT_NOT_APPROVED, () => isDirectApproval, DirectDenialDescription); stateMachine.Configure(GrantApplicationState.GRANT_NOT_APPROVED) @@ -160,9 +160,9 @@ private static bool AllowDirectDecision( return isDirectApproval && stateMachine.State != targetState; } - private bool HasPermission(string permission) + private async Task HasPermissionAsync(string permission) { - return _permissionChecker.IsGrantedAsync(permission).Result; + return await _permissionChecker.IsGrantedAsync(permission); } public async Task> GetActions(Guid applicationId) @@ -178,7 +178,7 @@ public async Task> GetActions(Guid application }); var allActions = Workflow.GetAllActions().Distinct().ToList(); - var permittedActions = Workflow.GetPermittedActions().ToList(); + var permittedActions = (await Workflow.GetPermittedActionsAsync()).ToList(); var actionsList = allActions .Select(trigger => @@ -194,7 +194,7 @@ public async Task> GetActions(Guid application return actionsList; } - public bool IsActionAllowed(Application application, GrantApplicationAction triggerAction) + public async Task IsActionAllowed(Application application, GrantApplicationAction triggerAction) { var Workflow = new UnityWorkflow( () => application.ApplicationStatus.StatusCode, @@ -203,7 +203,7 @@ public bool IsActionAllowed(Application application, GrantApplicationAction trig ConfigureWorkflow(sm, application.ApplicationForm.IsDirectApproval); }); - return Workflow.GetPermittedActions().Contains(triggerAction); + return (await Workflow.GetPermittedActionsAsync()).Contains(triggerAction); } /// @@ -326,7 +326,7 @@ public async Task RemoveAssigneeAsync(Guid applicationId, Guid assigneeId) using var uow = _unitOfWorkManager.Begin(); var person = await _personRepository.FindAsync(assigneeId) ?? throw new BusinessException("Tenant User Missing!"); var application = await _applicationRepository.GetAsync(applicationId, true); - IQueryable queryableAssignment = _applicationAssignmentRepository.GetQueryableAsync().Result; + IQueryable queryableAssignment = await _applicationAssignmentRepository.GetQueryableAsync(); List assignments = queryableAssignment .Where(a => a.ApplicationId.Equals(applicationId)) .Where(b => b.AssigneeId.Equals(person.Id)).ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationManager.cs index ce61915c33..73b4d7f2d0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationManager.cs @@ -12,6 +12,6 @@ public interface IApplicationManager Task> GetActions(Guid applicationId); Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); Task SetAssigneesAsync(Guid applicationId, List<(Guid? assigneeId, string? fullName)> assigneeSubs); - bool IsActionAllowed(Application application, GrantApplicationAction triggerAction); + Task IsActionAllowed(Application application, GrantApplicationAction triggerAction); string? GetWorkflowDiagram(bool isDirectApproval); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs index aae3a375aa..00451b61b1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/OnboardingApplicationManager.cs @@ -1,4 +1,4 @@ -using Stateless; +using Stateless; using System; using System.Collections.Generic; using System.Linq; @@ -40,7 +40,7 @@ public async Task> GetActions(Guid application ConfigureWorkflow); var allActions = workflow.GetAllActions().Distinct().ToList(); - var permittedActions = workflow.GetPermittedActions().ToList(); + var permittedActions = (await workflow.GetPermittedActionsAsync()).ToList(); return allActions .Select(trigger => new ApplicationActionResultItem @@ -53,7 +53,7 @@ public async Task> GetActions(Guid application .ToList(); } - public bool IsActionAllowed(Application application, GrantApplicationAction triggerAction) + public static async Task IsActionAllowed(Application application, GrantApplicationAction triggerAction) { var statusCode = application.ApplicationStatus.StatusCode; var workflow = new UnityWorkflow( @@ -61,7 +61,7 @@ public bool IsActionAllowed(Application application, GrantApplicationAction trig s => statusCode = s, ConfigureWorkflow); - return workflow.GetPermittedActions().Contains(triggerAction); + return (await workflow.GetPermittedActionsAsync()).Contains(triggerAction); } public async Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs index 977b656151..ec38d5d30d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs @@ -71,7 +71,7 @@ public async Task CreateAsync( assessorUser.Id), autoSave: true); - var isTransitionToAssessmentAllowed = _applicationManager.IsActionAllowed(application, GrantApplicationAction.Internal_StartAssessment); + var isTransitionToAssessmentAllowed = await _applicationManager.IsActionAllowed(application, GrantApplicationAction.Internal_StartAssessment); if (!hasOtherAssessments && isTransitionToAssessmentAllowed) { await _applicationManager.TriggerAction(application.Id, GrantApplicationAction.Internal_StartAssessment); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs index 9eb1d5451f..93a7f65f06 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs @@ -92,7 +92,7 @@ private static void AddSettingDefinition(ISettingDefinitionContext currentContex description, isVisibleToClients: true, isInherited: false, - isEncrypted: false).WithProviders(TenantSettingValueProvider.ProviderName) + isEncrypted: false).WithProviders(TenantSettingValueProvider.ProviderName, DefaultValueSettingValueProvider.ProviderName) ); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj index f1610fd590..8def4f7e90 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj @@ -15,22 +15,20 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + **/Assessments/Assessment.cs, **/Assessments/AssessmentWithAssessorQueryResultItem.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj index 867284829c..b5e9c1f7a9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj @@ -90,24 +90,22 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj index f530096675..6ed03b7432 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj @@ -14,11 +14,11 @@ - - - - - + + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs index 54c5a6ebc8..cf05a1abde 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs @@ -1,5 +1,7 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -47,6 +49,11 @@ public static bool IsInstalled(Func installationCheck) } } + // No [Authorize] anywhere means no authorization requirement at all here - this API has no + // application-level permission model of its own, so requiring an authenticated user is the + // floor: without it, upload/download actions are reachable by anonymous callers regardless of + // the authenticated pages that are meant to front them. + [Authorize] [Route("api/app/attachment")] public class AttachmentController : AbpController { @@ -57,7 +64,13 @@ public class AttachmentController : AbpController private readonly ICurrentTenant _currentTenant; private readonly ILibreOfficeConversionService _libreOfficeConversionService; private readonly IAttachmentPreviewAppService _attachmentPreviewAppService; - private ILogger Logger => LazyServiceProvider.LazyGetService(provider => LoggerFactory?.CreateLogger(GetType().FullName!) ?? NullLogger.Instance); + // LazyServiceProvider is populated via property injection when ASP.NET Core activates this + // controller through DI/routing. Unit tests that construct AttachmentController directly + // (new AttachmentController(...)) bypass that activation, leaving it null - guard against + // that so Logger.LogWarning/LogError calls don't crash tests with no interest in logging. + protected new ILogger Logger => LazyServiceProvider == null + ? NullLogger.Instance + : LazyServiceProvider.LazyGetService(provider => LoggerFactory?.CreateLogger(GetType().FullName!) ?? NullLogger.Instance); private const string badRequestFileMsg = "File name must be provided."; private const string NotFoundFileMsg = "File not found."; private const string errorFileMsg = "An error occurred while downloading the file."; @@ -226,6 +239,11 @@ public async Task DownloadAssessmentAttachment(string assessmentI } } + // Matches ISubmissionAppService.GetChefsFileAttachment, which this action calls directly - + // that method is deliberately [AllowAnonymous] (an external AI service depends on it), so + // the controller-level [Authorize] must not override it here or the two would silently + // disagree on this route's access model. + [AllowAnonymous] [HttpGet("chefs/{formSubmissionId}/download/{chefsFileId}/{fileName}")] public async Task DownloadChefsAttachment(Guid formSubmissionId, Guid chefsFileId, string fileName) { @@ -259,6 +277,8 @@ public async Task DownloadChefsAttachment(Guid formSubmissionId, } } + // Same rationale as DownloadChefsAttachment above - also calls GetChefsFileAttachment. + [AllowAnonymous] [HttpPost("chefs/download-all")] [Consumes("application/json")] public async Task DownloadAllChefsAttachment([FromBody] List input) @@ -371,6 +391,8 @@ public async Task PreviewApplicantAttachment(string applicantId, } } + // Same rationale as DownloadChefsAttachment above - also calls GetChefsFileAttachment. + [AllowAnonymous] [HttpGet("chefs/{formSubmissionId}/preview-pdf/{chefsFileId}/{fileName}")] public async Task PreviewChefsAttachment(Guid formSubmissionId, Guid chefsFileId, string fileName) { @@ -472,54 +494,62 @@ public async Task UploadEmailAttachments(Guid? emailLogId, Guid? return BadRequest(fileProvidedError); } - List invalidFileTypes = GetInvalidFileTypes(files); - if (invalidFileTypes.Count > 0) + List invalidMetadata = GetInvalidFileMetadata(files); + if (invalidMetadata.Count > 0) { - throw new AbpValidationException(message: "ERROR: Invalid File Type.", validationErrors: invalidFileTypes); + throw new AbpValidationException(message: "ERROR: Invalid File Type.", validationErrors: invalidMetadata); } + // Email-specific size checks are metadata-only (file.Length / a total-size lookup), + // so they run before any file is buffered into memory, same as GetInvalidFileMetadata. + // A missing OR malformed config value both fall back to the default and the check + // still runs - a parse failure must not silently disable enforcement. var emailAttachmentMaxFileSizeConfig = _configuration["S3:EmailAttachmentMaxFileSize"] ?? "20"; - if (double.TryParse(emailAttachmentMaxFileSizeConfig, out double maxFileSizeMB)) + if (!double.TryParse(emailAttachmentMaxFileSizeConfig, out double maxFileSizeMB) || maxFileSizeMB <= 0) { - var oversizedFiles = files.Where(f => f.Length * 0.000001 > maxFileSizeMB).ToList(); - if (oversizedFiles.Count > 0) - { - var sizeErrors = oversizedFiles.Select(f => - new ValidationResult($"File '{f.FileName}' exceeds the maximum allowed size of {maxFileSizeMB} MB for email attachments.", [f.FileName]) - ).ToList(); - throw new AbpValidationException("One or more files exceed the maximum allowed size for email attachments.", sizeErrors); - } + maxFileSizeMB = 20; + } + + var oversizedFiles = files.Where(f => f.Length * 0.000001 > maxFileSizeMB).ToList(); + if (oversizedFiles.Count > 0) + { + var sizeErrors = oversizedFiles.Select(f => + new ValidationResult($"File '{f.FileName}' exceeds the maximum allowed size of {maxFileSizeMB} MB for email attachments.", [f.FileName]) + ).ToList(); + throw new AbpValidationException("One or more files exceed the maximum allowed size for email attachments.", sizeErrors); } var totalMaxFileSizeConfig = _configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; - if (double.TryParse(totalMaxFileSizeConfig, out double totalMaxSizeMB)) - { - long existingTotalBytes = await _emailLogAttachmentUploadService - .GetTotalFileSizeByEmailLogIdAsync(emailLogId, templateId); - long newFilesBytes = files.Sum(f => f.Length); - double combinedMB = (existingTotalBytes + newFilesBytes) * 0.000001; - - if (combinedMB > totalMaxSizeMB) - { - throw new AbpValidationException( - $"The total size of all attachments ({combinedMB:F1} MB) would exceed the maximum allowed {totalMaxSizeMB} MB for email attachments. Please remove existing attachments or select a smaller file.", - [new ValidationResult("Total attachment size exceeds the allowed limit.")]); - } + if (!double.TryParse(totalMaxFileSizeConfig, out double totalMaxSizeMB) || totalMaxSizeMB <= 0) + { + totalMaxSizeMB = 25; } + long existingTotalBytes = await _emailLogAttachmentUploadService + .GetTotalFileSizeByEmailLogIdAsync(emailLogId, templateId); + long newFilesBytes = files.Sum(f => f.Length); + double combinedMB = (existingTotalBytes + newFilesBytes) * 0.000001; + + if (combinedMB > totalMaxSizeMB) + { + throw new AbpValidationException( + $"The total size of all attachments ({combinedMB:F1} MB) would exceed the maximum allowed {totalMaxSizeMB} MB for email attachments. Please remove existing attachments or select a smaller file.", + [new ValidationResult("Total attachment size exceeds the allowed limit.")]); + } + + List<(IFormFile File, byte[] Content)> fileEntries = await ReadFilesAsync(files); + var results = new List(); - foreach (var file in files) + foreach (var (file, content) in fileEntries) { try { - using var ms = new MemoryStream(); - await file.CopyToAsync(ms); var dto = await _emailLogAttachmentUploadService.UploadAsync( emailLogId, templateId, _currentTenant.Id, file.FileName, - ms.ToArray(), + content, file.ContentType ?? "application/octet-stream"); results.Add(dto); } @@ -535,23 +565,23 @@ public async Task UploadEmailAttachments(Guid? emailLogId, Guid? private async Task UploadFiles(IList files) { - List InvalidFileTypes = GetInvalidFileTypes(files); - if (InvalidFileTypes.Count > 0) + List invalidMetadata = GetInvalidFileMetadata(files); + if (invalidMetadata.Count > 0) { - throw new AbpValidationException(message: "ERROR: Invalid File Type.", validationErrors: InvalidFileTypes); + throw new AbpValidationException(message: "ERROR: Invalid File Type.", validationErrors: invalidMetadata); } + + List<(IFormFile File, byte[] Content)> fileEntries = await ReadFilesAsync(files); List ErrorList = []; - foreach (IFormFile source in files) + foreach (var (source, content) in fileEntries) { try { - using var memoryStream = new MemoryStream(); - await source.CopyToAsync(memoryStream); await _fileAppService.SaveBlobAsync( new SaveBlobInputDto { Name = source.FileName, - Content = memoryStream.ToArray() + Content = content }); } catch (Exception ex) @@ -569,26 +599,138 @@ await _fileAppService.SaveBlobAsync( return Ok("All Files Are Successfully Uploaded!"); } - private List GetInvalidFileTypes(IList files) + private static async Task> ReadFilesAsync(IList files) { - List ErrorList = []; - var InvalidFileTypes = _configuration["S3:DisallowedFileTypes"] ?? ""; - var DisallowedFileTypes = JsonConvert.DeserializeObject(InvalidFileTypes); - if (DisallowedFileTypes == null) + var fileEntries = new List<(IFormFile File, byte[] Content)>(); + foreach (var file in files) { - return ErrorList; + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + fileEntries.Add((file, memoryStream.ToArray())); } - foreach (var fileName in files.Where(file => + return fileEntries; + } + + // Extensions for which the browser-supplied ContentType is reliable enough to cross-check + // against the file extension. Plain text and email formats (txt/csv/eml/msg) are exempt + // because their ContentType reporting is inconsistent across browsers/OSes/mail clients, + // so only the allowlist and size checks apply to them. + private static readonly HashSet StrictlyValidatedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "gif", "zip", + "odt", "ods", "odp", "rtf", "bmp", "tif", "tiff", "webp", "heic", "heif" + }; + + private static string GetExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + if (extension.StartsWith('.')) { - string FileType = Path.GetExtension(file.FileName); - if (FileType.StartsWith('.')) + extension = extension[1..]; + } + return extension.ToLowerInvariant(); + } + + // Used when S3:AllowedFileTypes is missing or fails to parse, so a config gap degrades + // to this known-safe, already-reviewed set rather than silently rejecting every upload + // (fail-closed-to-nothing) or silently allowing anything (fail-open). + private static readonly string[] DefaultAllowedFileTypes = + [ + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "gif", "txt", "csv", "zip", + "odt", "ods", "odp", "rtf", "bmp", "tif", "tiff", "webp", "heic", "heif", "eml", "msg" + ]; + + private string[]? GetConfiguredFileTypesOrNull() + { + var allowedFileTypesConfig = _configuration["S3:AllowedFileTypes"]; + if (string.IsNullOrWhiteSpace(allowedFileTypesConfig)) + { + return null; + } + + try + { + var allowedFileTypes = JsonConvert.DeserializeObject(allowedFileTypesConfig) + // A syntactically valid array can still contain a null/blank entry (e.g. a + // trailing comma typo producing ["pdf", null]) - without filtering these out, + // the ToLowerInvariant() call below would throw on the null element, turning a + // config typo into an unhandled 500 on every upload instead of the graceful + // fallback this whole method exists to provide. + ?.Where(t => !string.IsNullOrWhiteSpace(t)) + .ToArray(); + if (allowedFileTypes == null || allowedFileTypes.Length == 0) { - FileType = FileType[1..]; + Logger.LogWarning("AttachmentController: S3:AllowedFileTypes was empty; falling back to the default allowlist."); + return null; } - return DisallowedFileTypes.Contains(FileType.ToLower()); - }).Select(source => source.FileName)) + return allowedFileTypes; + } + catch (JsonException ex) + { + Logger.LogWarning(ex, "AttachmentController: S3:AllowedFileTypes could not be parsed as a JSON string array; falling back to the default allowlist."); + return null; + } + } + + // DefaultAllowedFileTypes is only a fallback for when S3:AllowedFileTypes is missing or + // fails to parse - deliberately NOT a ceiling. When config is present, it is the effective + // allowlist outright, including any extension not in the default set (e.g. "exe"/"jsp"). + // This is an intentional operational trust decision: config is set by whoever controls the + // deployment environment (devops), not by a remote/anonymous caller, so an operator adding + // a type here is a deliberate choice they own, not something this controller should second- + // guess. A config typo introducing a dangerous extension is a real risk under this design - + // accepted in exchange for devops being able to extend the allowlist without a code change. + private string[] GetAllowedFileTypes() + { + var configuredFileTypes = GetConfiguredFileTypesOrNull(); + if (configuredFileTypes == null) { - ErrorList.Add(new ValidationResult("Invalid file type for " + fileName, [nameof(fileName)])); + return DefaultAllowedFileTypes; + } + + return configuredFileTypes.Select(t => t.ToLowerInvariant()).Distinct().ToArray(); + } + + // Per-file checks that require no stream I/O (extension allowlist, browser-supplied + // Content-Type consistency, and declared size). Run these before ever reading a file's + // bytes, so an invalid or oversized file is rejected without being buffered into memory. + private List GetInvalidFileMetadata(IList files) + { + List ErrorList = []; + var AllowedFileTypes = GetAllowedFileTypes(); + var contentTypeProvider = new FileExtensionContentTypeProvider(); + + var maxFileSizeConfig = _configuration["S3:MaxFileSize"] ?? "25"; + if (!double.TryParse(maxFileSizeConfig, out double maxFileSizeMB) || maxFileSizeMB <= 0) + { + maxFileSizeMB = 25; + } + + foreach (var file in files) + { + var fileName = file.FileName; + var extension = GetExtension(fileName); + + if (!AllowedFileTypes.Contains(extension)) + { + ErrorList.Add(new ValidationResult("Invalid file type for " + fileName, [nameof(fileName)])); + continue; + } + + if (StrictlyValidatedExtensions.Contains(extension) && + contentTypeProvider.TryGetContentType(fileName, out var expectedContentType) && + !string.IsNullOrWhiteSpace(file.ContentType) && + !string.Equals(file.ContentType, "application/octet-stream", StringComparison.OrdinalIgnoreCase) && + !string.Equals(expectedContentType.Split('/')[0], file.ContentType.Split('/')[0], StringComparison.OrdinalIgnoreCase)) + { + ErrorList.Add(new ValidationResult("File content type does not match its extension for " + fileName, [nameof(fileName)])); + continue; + } + + if (file.Length * 0.000001 > maxFileSizeMB) + { + ErrorList.Add(new ValidationResult($"File '{fileName}' exceeds the maximum allowed size of {maxFileSizeMB} MB.", [nameof(fileName)])); + } } return ErrorList; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj index d174f23920..ae807c1d10 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj @@ -15,14 +15,11 @@ - - - - - - - - + + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 3388846e2d..b4af2379fc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -199,7 +199,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) Configure(options => { - options.TokenCookie.Expiration = TimeSpan.FromDays(365); + options.TokenCookie.Expiration = TimeSpan.FromHours(8); options.TokenCookie.SecurePolicy = CookieSecurePolicy.Always; options.TokenCookie.SameSite = SameSiteMode.Lax; options.TokenCookie.HttpOnly = false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml index 5f8a17be6c..51a67ef65e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml @@ -65,7 +65,7 @@ - + @if (Model.ApplicantIsDeleted) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs index 998b75cc1c..bd48ebec63 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs @@ -28,7 +28,7 @@ public class DetailsModel : GrantManagerPageModel public string Status { get; set; } = string.Empty; public Guid? CurrentUserId { get; set; } public string CurrentUserName { get; set; } - public string Extensions { get; set; } = string.Empty; + public string AllowedFileTypes { get; set; } = string.Empty; public string MaxFileSize { get; set; } = string.Empty; public DetailsModel( @@ -41,7 +41,7 @@ public DetailsModel( _applicationRepository = applicationRepository; CurrentUserId = currentUser.Id; CurrentUserName = currentUser.SurName + ", " + currentUser.Name; - Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; + AllowedFileTypes = configuration["S3:AllowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js index da939d3d9a..4609dd3ab5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js @@ -396,9 +396,17 @@ function uploadFiles(inputId, urlStr, channel) { let input = document.getElementById(inputId); let files = input.files; let formData = new FormData(); - const disallowedTypes = JSON.parse( - decodeURIComponent($('#Extensions').val()) - ); + let allowedTypes; + try { + allowedTypes = JSON.parse(decodeURIComponent($('#AllowedFileTypes').val())); + if (!Array.isArray(allowedTypes)) { + throw new TypeError('AllowedFileTypes did not parse to an array'); + } + } catch (e) { + console.warn('Unable to parse allowed file types configuration:', e); + abp.notify.error('Unable to determine allowed file types. Please contact support.'); + return; + } const maxFileSize = decodeURIComponent($('#MaxFileSize').val()); let isAllowedTypeError = false; @@ -409,7 +417,7 @@ function uploadFiles(inputId, urlStr, channel) { for (let file of files) { if ( - disallowedTypes.includes( + !allowedTypes.includes( file.name .slice(file.name.lastIndexOf('.') + 1, file.name.length) .toLowerCase() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index d722b2ce1b..e7e05eb9e1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -60,6 +60,13 @@ btnGenerate: $('#btn-generate'), btnGenerateWorksheet: $('#btn-generate-worksheet'), btnGenerateScoresheet: $('#btn-generate-scoresheet'), + btnReviewWorksheet: $('#btn-review-worksheet'), + worksheetReviewModal: $('#aiWorksheetReviewModal'), + worksheetReviewFields: $('#aiWorksheetReviewFields'), + worksheetReviewEmpty: $('#aiWorksheetReviewEmpty'), + worksheetTitle: $('#aiWorksheetTitle'), + btnCreateWorksheetDraft: $('#btn-create-ai-worksheet-draft'), + btnDiscardWorksheet: $('#btn-discard-ai-worksheet'), btnSync: $('#btn-sync'), btnReset: $('#btn-reset'), btnClose: $('.btn-close'), @@ -103,6 +110,12 @@ UIElements.btnGenerate.on('click', queueFormMapping); UIElements.btnGenerateWorksheet.on('click', queueFormWorksheet); UIElements.btnGenerateScoresheet.on('click', queueFormScoresheet); + UIElements.btnReviewWorksheet.on('click', loadAiWorksheetReview); + UIElements.btnCreateWorksheetDraft.on('click', createAiWorksheetDraft); + UIElements.btnDiscardWorksheet.on('click', discardAiWorksheetSuggestions); + UIElements.worksheetReviewFields.on('change', 'input[data-field-id]', updateAiWorksheetReview); + $('#aiWorksheetReviewSelectAll').on('change', toggleAiWorksheetReviewAll); + UIElements.worksheetTitle.on('input', updateAiWorksheetDraftButton); UIElements.btnReset.on('click', handleReset); UIElements.btnCancel.on('click', handleCancelMapping); UIElements.btnClose.on('click', handleCancelMapping); @@ -217,6 +230,12 @@ const buttonElement = triggerButton?.currentTarget || triggerButton?.target || triggerButton || UIElements.btnGenerateWorksheet?.get?.(0); const $button = $(buttonElement); + + if (isAiWorksheetPending()) { + loadAiWorksheetReview(); + return; + } + const existingHtml = $button.html(); if ($button.prop('disabled')) { @@ -338,10 +357,195 @@ } function refreshWorksheetAfterGeneration() { - abp.notify.success('', 'Worksheet generated and assigned successfully. Reloading page.'); - setTimeout(function () { - globalThis.location.reload(); - }, 500); + setAiWorksheetPending(true); + abp.notify.success('', 'Worksheet generated. Review the suggested fields and create draft worksheets.'); + loadAiWorksheetReview(); + } + + function isAiWorksheetPending() { + return UIElements.btnGenerateWorksheet.attr('data-ai-pending') === 'true'; + } + + function setAiWorksheetPending(isPending) { + UIElements.btnGenerateWorksheet + .attr('data-ai-pending', isPending ? 'true' : 'false') + .toggleClass('d-none', isPending); + UIElements.btnReviewWorksheet.toggleClass('d-none', !isPending); + + if (!isPending) { + globalThis.syncAIRateLimitButtons?.(); + } + } + + function loadAiWorksheetReview() { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + if (!validateGuid(formVersion)) { + abp.notify.error('', 'Unable to review the worksheet because the Form Version ID is invalid.'); + return; + } + + abp.ajax({ + url: `/api/app/application-form-version/pending-ai-worksheet?formVersionId=${encodeURIComponent(formVersion)}`, + type: 'GET' + }) + .done(function (worksheet) { + if (!worksheet) { + setAiWorksheetPending(false); + abp.notify.error('', 'The pending AI worksheet is no longer available.'); + return; + } + + renderAiWorksheetReview(worksheet); + UIElements.worksheetReviewModal.modal('show'); + }) + .fail(function () { + abp.notify.error('', 'Unable to load the pending AI worksheet.'); + }); + } + + function renderAiWorksheetReview(worksheet) { + UIElements.worksheetReviewFields.empty(); + + const fields = worksheet.fields || []; + fields.forEach(function (field) { + const fieldId = `ai-worksheet-field-${field.id}`; + const $row = $(''); + $('') + .attr('data-field-role', 'Source') + .text(field.key || '—') + .appendTo($row); + $('').appendTo($row); + $('') + .attr('data-field-role', 'Worksheet') + .text(field.label || field.key || '—') + .appendTo($row); + const $switch = $(''); + const $switchContainer = $(''); + $('') + .attr('id', fieldId) + .attr('data-field-id', field.id) + .attr('aria-label', `Include ${field.label || field.key || 'field'}`) + .prop('checked', field.selected !== false) + .appendTo($switchContainer); + $switchContainer.appendTo($switch); + $switch.appendTo($row); + $row.appendTo(UIElements.worksheetReviewFields); + }); + + UIElements.worksheetReviewFields.attr('data-session-id', worksheet.sessionId); + UIElements.worksheetReviewEmpty.toggleClass('d-none', fields.length > 0); + updateAiWorksheetReview(); + } + + function updateAiWorksheetReview() { + const $fields = UIElements.worksheetReviewFields.find('input[data-field-id]'); + const selectedCount = $fields.filter(':checked').length; + $('#aiWorksheetReviewSelectAll') + .prop('checked', $fields.length > 0 && selectedCount === $fields.length) + .prop('indeterminate', false); + updateAiWorksheetDraftButton(); + } + + function toggleAiWorksheetReviewAll() { + UIElements.worksheetReviewFields.find('input[data-field-id]').prop('checked', $(this).prop('checked')); + updateAiWorksheetReview(); + } + + function updateAiWorksheetDraftButton() { + const hasTitle = String(UIElements.worksheetTitle.val() ?? '').trim().length > 0; + const hasSelectedFields = UIElements.worksheetReviewFields.find('input[data-field-id]:checked').length > 0; + UIElements.btnCreateWorksheetDraft.prop('disabled', !hasTitle || !hasSelectedFields); + } + + function createAiWorksheetDraft() { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + const sessionId = UIElements.worksheetReviewFields.attr('data-session-id'); + const title = String(UIElements.worksheetTitle.val() ?? '').trim(); + const selectedFieldIds = UIElements.worksheetReviewFields + .find('input[data-field-id]:checked') + .map(function () { return $(this).attr('data-field-id'); }) + .get(); + + if (!validateGuid(formVersion) || !validateGuid(sessionId) || !title || selectedFieldIds.length === 0) { + abp.notify.error('', 'Enter a worksheet title and select at least one suggested field.'); + return; + } + + UIElements.btnCreateWorksheetDraft.prop('disabled', true); + UIElements.btnDiscardWorksheet.prop('disabled', true); + + abp.ajax({ + url: `/api/app/application-form-version/create-ai-worksheet-draft?formVersionId=${encodeURIComponent(formVersion)}`, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ sessionId, title, selectedFieldIds }) + }) + .done(function () { + UIElements.worksheetTitle.val(''); + abp.notify.success('', 'Draft worksheet created.'); + refreshAiWorksheetReviewAfterDraftCreation(formVersion); + }) + .fail(function () { + abp.notify.error('', 'Unable to create the draft worksheet.'); + }) + .always(function () { + UIElements.btnDiscardWorksheet.prop('disabled', false); + updateAiWorksheetDraftButton(); + }); + } + + function refreshAiWorksheetReviewAfterDraftCreation(formVersion) { + abp.ajax({ + url: `/api/app/application-form-version/pending-ai-worksheet?formVersionId=${encodeURIComponent(formVersion)}`, + type: 'GET' + }) + .done(function (worksheet) { + if (!worksheet) { + UIElements.worksheetReviewModal.modal('hide'); + setAiWorksheetPending(false); + return; + } + + renderAiWorksheetReview(worksheet); + }) + .fail(function () { + abp.notify.error('', 'Draft created, but the remaining suggestions could not be loaded.'); + }); + } + + function discardAiWorksheetSuggestions() { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + if (!validateGuid(formVersion) || !isAiWorksheetPending()) { + return; + } + + abp.message.confirm( + 'This will permanently remove the remaining AI field suggestions.', + 'Discard remaining suggestions?') + .then(function (confirmed) { + if (!confirmed) { + return; + } + + UIElements.btnCreateWorksheetDraft.prop('disabled', true); + UIElements.btnDiscardWorksheet.prop('disabled', true); + abp.ajax({ + url: `/api/app/application-form-version/discard-ai-worksheet-suggestions?formVersionId=${encodeURIComponent(formVersion)}`, + type: 'POST' + }) + .done(function () { + setAiWorksheetPending(false); + UIElements.worksheetReviewModal.modal('hide'); + abp.notify.success('', 'Remaining AI worksheet suggestions discarded.'); + }) + .fail(function () { + abp.notify.error('', 'Unable to discard the remaining AI worksheet suggestions.'); + }) + .always(function () { + UIElements.btnDiscardWorksheet.prop('disabled', false); + updateAiWorksheetDraftButton(); + }); + }); } function refreshScoresheetAfterGeneration() { @@ -424,7 +628,7 @@ globalThis.AIGenerationButtonState?.restore($button); $button.html(existingHtml).prop('disabled', false); - $button.find('span').last().text('Generate Worksheet'); + $button.find('span').last().text(isAiWorksheetPending() ? 'Review Worksheet' : 'Generate Worksheet'); } function restoreGenerateScoresheetButton($button, existingHtml) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 7dee856075..cceadeda4c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -37,7 +37,7 @@ // View guards — control tab visibility and loading of existing AI results var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") - && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.View); + && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.View); // Shared manual-generation guards var tenantManualEnabled = await SettingProvider.GetAsync(AISettings.ManualGenerationEnabled, defaultValue: false); @@ -48,19 +48,19 @@ var aiApplicationAnalysisGenerateEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") && tenantManualEnabled && formManualEnabled - && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.Generate); + && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.Generate); } @section styles { } -@section scripts -{ - - - - +@section scripts +{ + + + + } @@ -79,7 +79,7 @@ - + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index 9181e20437..f01f08326a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -74,7 +74,7 @@ public class DetailsModel : AbpPageModel [BindProperty(SupportsGet = true)] public string? CurrentUserName { get; set; } - public string Extensions { get; set; } + public string AllowedFileTypes { get; set; } public string MaxFileSize { get; set; } public string EmailAttachmentMaxFileSize { get; set; } public string TotalEmailAttachmentMaxFileSize { get; set; } @@ -107,7 +107,7 @@ public DetailsModel( CurrentUserId = currentUser.Id; CurrentUserName = currentUser.SurName + ", " + currentUser.Name; - Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; + AllowedFileTypes = configuration["S3:AllowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; EmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentMaxFileSize"] ?? "20"; TotalEmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 8cd3ee2d88..072af00edd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -982,9 +982,17 @@ function uploadFiles(inputId, urlStr, channel) { let input = document.getElementById(inputId); let files = input.files; let formData = new FormData(); - const disallowedTypes = JSON.parse( - decodeURIComponent($('#Extensions').val()) - ); + let allowedTypes; + try { + allowedTypes = JSON.parse(decodeURIComponent($('#AllowedFileTypes').val())); + if (!Array.isArray(allowedTypes)) { + throw new TypeError('AllowedFileTypes did not parse to an array'); + } + } catch (e) { + console.warn('Unable to parse allowed file types configuration:', e); + abp.notify.error('Unable to determine allowed file types. Please contact support.'); + return; + } const maxFileSize = decodeURIComponent($('#MaxFileSize').val()); let isAllowedTypeError = false; @@ -995,7 +1003,7 @@ function uploadFiles(inputId, urlStr, channel) { for (let file of files) { if ( - disallowedTypes.includes( + !allowedTypes.includes( file.name .slice(file.name.lastIndexOf('.') + 1, file.name.length) .toLowerCase() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Properties/launchSettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Properties/launchSettings.json index 0fd5cc8908..3e73825314 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Properties/launchSettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Properties/launchSettings.json @@ -13,7 +13,8 @@ "launchBrowser": true, "launchUrl": "https://localhost:44342/", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "AI__FormWorksheet__UseMock": "true" } }, "Unity.GrantManager.Web": { @@ -21,8 +22,9 @@ "launchBrowser": true, "applicationUrl": "https://localhost:44342/", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "AI__FormWorksheet__UseMock": "true" } } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj index cc87311b1a..c1366b7ee4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj @@ -47,8 +47,8 @@ - - + + all runtime; @@ -61,24 +61,26 @@ - - + + - - + + - - - - - - - - + + + + + + + + - + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs index 5510427687..5650982d31 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs @@ -8,6 +8,7 @@ using System.Linq; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.ApplicationForms.Mapping; using Unity.Flex.Worksheets; using Unity.Flex.WorksheetLinks; using Unity.GrantManager.Flex; @@ -43,7 +44,14 @@ public async Task InvokeAsync(string? formVersionId, strin model.ChefsFormPublished = formVersion?.Published; model.WorksheetLinks = await worksheetLinkAppService.GetListByCorrelationAsync(formVersion?.Id ?? Guid.Empty, CorrelationConsts.FormVersion); - model.PublishedWorksheets = [.. (await worksheetListAppService.GetListAsync()) + var aiSuggestionWorksheetName = formVersion == null + ? string.Empty + : AiWorksheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id); + var worksheets = await worksheetListAppService.GetListAsync(); + model.HasPendingAiWorksheet = worksheets + .Any(worksheet => !worksheet.Published && worksheet.Name == aiSuggestionWorksheetName); + + model.PublishedWorksheets = [.. worksheets .Where(s => s.Published && !model.WorksheetLinks.Select(s => s.WorksheetId).Contains(s.Id)) .OrderBy(s => s.Title)]; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs index 95169c2de8..c0e544c163 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs @@ -37,7 +37,9 @@ public class CustomFieldsViewModel public List? FundingAgreementInfoLinks { get; set; } public List? CustomTabLinks { get; set; } + public bool HasPendingAiWorksheet { get; set; } + [Display(Name = "")] public Guid? ScoresheetId { get; set; } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml index 2a4d1a37f2..579a7aaebf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml @@ -23,14 +23,21 @@ form="worksheet-config-form" /> @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate)) { - Generate Worksheet + + + + Review Worksheet + + } @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.Generate)) { @@ -182,3 +189,40 @@ + + + + + + + + Create worksheet draft + + + + + Worksheet title + + + + + Source field + + Worksheet field + + + + + + No custom fields were suggested. + + + + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css index b401b9d45c..e5511e4b19 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css @@ -111,3 +111,203 @@ overflow: auto; height: calc(100vh - 250px); } + +.ai-worksheet-review { + border: 0; + border-radius: 10px; + overflow: hidden; +} + +.ai-worksheet-review__panel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; +} + +.ai-worksheet-review__panel > .modal-header, +.ai-worksheet-review__panel > .modal-body, +.ai-worksheet-review__panel > .modal-footer { + margin: 0; + padding: 0; +} + +.ai-worksheet-review__body { + display: flex; + flex: 0 1 auto; + flex-direction: column; + gap: 12px; + max-height: min(65vh, 42rem); +} + +.ai-worksheet-review__list { + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; +} + +.ai-worksheet-review__header { + border-bottom: 0; +} + +.ai-worksheet-review .modal-title { + color: #1f2933; + font-size: 1.25rem; + font-weight: 600; + line-height: 1.3; + margin: 0; +} + +.ai-worksheet-review__title-group .form-label { + color: #334e68; + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.ai-worksheet-review__title-group .form-control { + border-color: #b8c7d6; + font-size: 0.9375rem; +} + +.ai-worksheet-review__title-group .form-control:focus { + border-color: var(--bc-colors-blue-primary, #255a90); + box-shadow: 0 0 0 0.2rem rgb(37 90 144 / 15%); +} + +.ai-worksheet-review__table-header, +.ai-worksheet-review__field { + display: grid; + grid-template-columns: minmax(0, 1fr) 2rem minmax(0, 1fr) auto; +} + +.ai-worksheet-review__table-header { + align-items: center; + background: #f4f7fa; + border-bottom: 1px solid #d8e1eb; + color: #486581; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.05em; + min-height: 2.5rem; + padding-inline: 0.75rem; + position: sticky; + top: 0; + z-index: 1; + text-transform: uppercase; +} + +.ai-worksheet-review__field { + align-items: center; + min-height: 3.5rem; + padding-inline: 0.75rem; +} + +.ai-worksheet-review__field + .ai-worksheet-review__field { + border-top: 1px solid #edf1f5; +} + +.ai-worksheet-review__field-name { + color: #1f2933; + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.ai-worksheet-review__arrow { + color: #52789d; + justify-self: center; +} + +.ai-worksheet-review__switch { + justify-self: end; +} + +.ai-worksheet-review__field:hover { + background: #f9fbfd; +} + +.ai-worksheet-review__select-all { + align-items: center; + display: flex; + justify-self: end; +} + +.ai-worksheet-review__empty { + color: #486581; + text-align: center; +} + +.ai-worksheet-review__footer { + border-top: 0; + gap: 0.5rem; + justify-content: flex-start; +} + +.ai-worksheet-review__footer > * { + margin: 0; +} + +.ai-worksheet-review__footer .btn { + font-size: inherit; + font-weight: 400; +} + +.ai-worksheet-review__discard { + --bs-btn-color: #b42318; + --bs-btn-border-color: #b42318; + --bs-btn-hover-bg: #b42318; + --bs-btn-hover-border-color: #b42318; + --bs-btn-hover-color: #fff; +} + +@media (max-width: 767.98px) { + .ai-worksheet-review__body { + max-height: 70vh; + } + + .ai-worksheet-review__table-header { + display: flex; + justify-content: flex-end; + } + + .ai-worksheet-review__table-header > span { + display: none; + } + + .ai-worksheet-review__field { + grid-template-columns: minmax(0, 1fr) 1.5rem auto; + row-gap: 0.5rem; + } + + .ai-worksheet-review__field-name:first-child { + grid-column: 1 / 2; + } + + .ai-worksheet-review__field-name:nth-child(3) { + grid-column: 1 / 2; + } + + .ai-worksheet-review__field-name::before { + color: #596777; + content: attr(data-field-role); + display: block; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.05em; + margin-bottom: 0.125rem; + text-transform: uppercase; + } + + .ai-worksheet-review__arrow { + grid-column: 2 / 3; + grid-row: 1 / 3; + } + + .ai-worksheet-review__switch { + grid-column: 3 / 4; + grid-row: 1 / 3; + } + +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css index c98d1ae801..1a95daec66 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css @@ -30,7 +30,6 @@ /* Force fixed table layout to prevent column expansion */ #EmailHistoryTable { width: 100% !important; - table-layout: fixed !important; box-sizing: border-box; } @@ -51,6 +50,10 @@ white-space: normal; } +.btn-delete-delayed { + width: 30px !important; +} + @media (max-height: 768px) { .dt-scroll-body { max-height: inherit !important; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index 30b67ec0a3..1917dabff4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -2379,7 +2379,17 @@ const input = document.getElementById(inputId); if (!input?.files?.length) return; - const disallowedTypes = JSON.parse(decodeURIComponent($('#Extensions').val())); + let allowedTypes; + try { + allowedTypes = JSON.parse(decodeURIComponent($('#AllowedFileTypes').val())); + if (!Array.isArray(allowedTypes)) { + throw new TypeError('AllowedFileTypes did not parse to an array'); + } + } catch (e) { + console.warn('Unable to parse allowed file types configuration:', e); + abp.notify.error('Unable to determine allowed file types. Please contact support.'); + return; + } const maxFileSize = decodeURIComponent($('#EmailAttachmentMaxFileSize').val()); let isAllowedTypeError = false; @@ -2388,7 +2398,7 @@ for (let file of input.files) { const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase(); - if (disallowedTypes.includes(ext)) { + if (!allowedTypes.includes(ext)) { isAllowedTypeError = true; } if (file.size * 0.000001 > maxFileSize) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index e6a9930983..ec48801416 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -77,7 +77,7 @@ "ApplicantS3Folder": "Unity/Applicant", "ApplicationS3Folder": "Unity/Application", "AssessmentS3Folder": "Unity/Adjudication", - "DisallowedFileTypes": "[ \"exe\",\"sh\",\"ksh\",\"bat\",\"cmd\" ]", + "AllowedFileTypes": "[ \"pdf\",\"doc\",\"docx\",\"xls\",\"xlsx\",\"ppt\",\"pptx\",\"jpg\",\"jpeg\",\"png\",\"gif\",\"txt\",\"csv\",\"zip\",\"odt\",\"ods\",\"odp\",\"rtf\",\"bmp\",\"tif\",\"tiff\",\"webp\",\"heic\",\"heif\",\"eml\",\"msg\" ]", "MaxFileSize": 25, "EmailAttachmentMaxFileSize": 20, "EmailAttachmentsTotalMaxFileSize": 25 diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs index 009c08dbf6..1ea11d1bd6 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using Shouldly; +using Unity.Flex.Worksheets; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Xunit; @@ -9,7 +11,12 @@ public class GenerateFormWorksheetJobTests { [Theory] [InlineData("{}")] - [InlineData("""{"Title":"Draft","Sections":[]}""")] + [InlineData("""{"fields":[{"key":"","label":"Project","type":"Text"}]}""")] + [InlineData("""{"fields":[{"key":"project","label":"","type":"Text"}]}""")] + [InlineData("""{"fields":[{"key":"project","label":"Project","type":"Radio"}]}""")] + [InlineData("""{"fields":[{"key":"project","label":"Project","type":2}]}""")] + [InlineData("""{"fields":[{"key":"project","label":"Project","type":" "}]}""")] + [InlineData("""{"fields":[{"key":"project","label":"Project","type":"Text"},{"key":"PROJECT","label":"Other","type":"Text"}]}""")] public void ParseWorksheetDefinition_Should_Reject_Incomplete_Ai_Response(string worksheetJson) { var exception = Should.Throw(() => @@ -17,4 +24,43 @@ public void ParseWorksheetDefinition_Should_Reject_Incomplete_Ai_Response(string exception.Message.ShouldContain("unusable worksheet definition"); } + + [Fact] + public void ParseWorksheetDefinition_Should_Accept_Flat_Safe_Field_Suggestions() + { + var fields = GenerateFormWorksheetJob.ParseWorksheetDefinition(""" + {"fields":[{"key":"projectName","label":"Project Name","type":"Text"},{"key":"requestedAmount","label":"Requested Amount","type":"Currency"}]} + """); + + fields.Count.ShouldBe(2); + fields[0].Key.ShouldBe("projectName"); + fields[0].ResolvedType.ShouldBe(CustomFieldType.Text); + fields[1].ResolvedType.ShouldBe(CustomFieldType.Currency); + } + + [Fact] + public void ParseWorksheetDefinition_Should_Trim_And_Ignore_Case_For_Safe_Field_Types() + { + var fields = GenerateFormWorksheetJob.ParseWorksheetDefinition(""" + {"fields":[{"key":"projectName","label":"Project Name","type":" teXT "}]} + """); + + fields.Single().ResolvedType.ShouldBe(CustomFieldType.Text); + } + + [Fact] + public void BuildWorksheet_Should_Create_One_SuggestedFields_Section_With_Default_Definitions() + { + var suggestions = GenerateFormWorksheetJob.ParseWorksheetDefinition(""" + {"fields":[{"key":"projectName","label":"Project Name","type":"Text"}]} + """); + + var worksheet = GenerateFormWorksheetJob.BuildWorksheet(suggestions, "ai-form-worksheet"); + + worksheet.Sections.Count.ShouldBe(1); + worksheet.Sections.Single().Name.ShouldBe("Suggested Fields"); + var field = worksheet.Sections.Single().Fields.Single(); + field.Order.ShouldBe(1u); + field.Definition.ShouldContain("maxLength"); + } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs index 7f12a33ae7..a83d69fa7d 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs @@ -188,14 +188,13 @@ public void ValidateFormWorksheetJson_Should_Return_InvalidOutput_For_Incomplete } [Fact] - public void ValidateFormWorksheetJson_Should_Return_Success_For_Complete_Worksheet() + public void ValidateFormWorksheetJson_Should_Return_Success_For_Flat_Field_Suggestions() { var result = AIProviderPayloadValidator.ValidateFormWorksheetJson( """ { - "title": "Draft", - "sections": [ - { "name": "Application details", "fields": [] } + "fields": [ + { "key": "projectName", "label": "Project Name", "type": "Text" } ] } """); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs index ea1b8d3468..ca37cd67bf 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text.Json; using System.Threading.Tasks; using Unity.AI; using Unity.AI.Cooldown; @@ -96,6 +97,187 @@ public void FormMappingPromptData_Should_UseEmptyObject_When_NoExistingMappingIs promptData.GetProperty("existingMapping").GetRawText().ShouldBe("{}"); } + [Fact] + public async Task GetPendingAiWorksheetAsync_Should_Return_Unpublished_Worksheet_Fields() + { + var formVersionId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion + { + ApplicationFormId = formId + }; + var worksheet = BuildAiWorksheet(formId, formVersionId, published: false); + var formVersionRepository = Substitute.For(); + formVersionRepository.GetAsync(formVersionId).Returns(formVersion); + var worksheetRepository = Substitute.For(); + worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(worksheet); + + var service = CreateService( + Substitute.For>(), + Substitute.For(), + Substitute.For(), + formVersionRepository, + worksheetRepository); + service.LazyServiceProvider = GetRequiredService(); + + var result = await service.GetPendingAiWorksheetAsync(formVersionId); + + result.ShouldNotBeNull(); + result!.SessionId.ShouldBe(worksheet.Id); + result.Fields.Single().Label.ShouldBe("Project Name"); + result.Fields.Single().Selected.ShouldBeTrue(); + } + + [Fact] + public async Task DiscardAiWorksheetSuggestionsAsync_Should_Not_Delete_Published_Containers() + { + var formVersionId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion { ApplicationFormId = formId }; + var publishedSuggestion = BuildAiWorksheet(formId, formVersionId, published: true); + var formVersionRepository = Substitute.For(); + formVersionRepository.GetAsync(formVersionId).Returns(formVersion); + var worksheetRepository = Substitute.For(); + worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(publishedSuggestion); + + var service = CreateService( + Substitute.For>(), + Substitute.For(), + Substitute.For(), + formVersionRepository, + worksheetRepository); + service.LazyServiceProvider = GetRequiredService(); + + await service.DiscardAiWorksheetSuggestionsAsync(formVersionId); + + await worksheetRepository.DidNotReceive().DeleteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CreateAiWorksheetDraftAsync_Should_Create_Unlinked_Unpublished_Draft_And_Keep_Remaining_Suggestions() + { + var formVersionId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion + { + ApplicationFormId = formId + }; + var worksheet = BuildAiWorksheet(formId, formVersionId, published: false, fieldCount: 2); + var formVersionRepository = Substitute.For(); + formVersionRepository.GetAsync(formVersionId).Returns(formVersion); + var worksheetRepository = Substitute.For(); + worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(worksheet); + var customFieldRepository = Substitute.For>(); + Worksheet? createdDraft = null; + worksheetRepository.InsertAsync(Arg.Do(worksheet => createdDraft = worksheet), true) + .Returns(Task.FromResult(null!)); + + var service = CreateService( + Substitute.For>(), + Substitute.For(), + Substitute.For(), + formVersionRepository, + worksheetRepository, + customFieldRepository); + service.LazyServiceProvider = GetRequiredService(); + + var selectedFieldId = worksheet.Sections.Single().Fields.First().Id; + var remainingFieldId = worksheet.Sections.Single().Fields.Last().Id; + worksheet.Sections.Single().Fields.First().SetDefinition(JsonSerializer.Serialize("""{"required":true}""")); + await service.CreateAiWorksheetDraftAsync(formVersionId, new CreateAiWorksheetDraftDto + { + SessionId = worksheet.Id, + Title = "Risk Review", + SelectedFieldIds = [selectedFieldId] + }); + + createdDraft.ShouldNotBeNull(); + createdDraft!.Name.ShouldBe("ai-risk-review"); + createdDraft.Title.ShouldBe("Risk Review"); + createdDraft.Published.ShouldBeFalse(); + createdDraft.Links.ShouldBeEmpty(); + createdDraft.Sections.Single().Fields.Single().Key.ShouldBe("Field0"); + createdDraft.Sections.Single().Fields.Single().Label.ShouldBe("Project Name"); + createdDraft.Sections.Single().Fields.Single().Definition.ShouldBe("""{"required":true}"""); + worksheet.Published.ShouldBeFalse(); + worksheet.Sections.Single().Fields.Select(field => field.Id).ShouldBe([remainingFieldId]); + await customFieldRepository.Received(1).DeleteAsync(selectedFieldId); + await worksheetRepository.Received(1).UpdateAsync(worksheet, true); + } + + [Fact] + public async Task CreateAiWorksheetDraftAsync_Should_Number_Internal_Name_And_Delete_Empty_Suggestions() + { + var formVersionId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion + { + ApplicationFormId = formId + }; + var worksheet = BuildAiWorksheet(formId, formVersionId, published: false, fieldCount: 2); + var formVersionRepository = Substitute.For(); + formVersionRepository.GetAsync(formVersionId).Returns(formVersion); + var worksheetRepository = Substitute.For(); + worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(worksheet); + var customFieldRepository = Substitute.For>(); + Worksheet? createdDraft = null; + worksheetRepository.GetByNameAsync("ai-risk-review", false).Returns(new Worksheet(Guid.NewGuid(), "ai-risk-review", "Existing")); + worksheetRepository.GetByNameAsync("ai-risk-review-2", false).Returns((Worksheet?)null); + worksheetRepository.InsertAsync(Arg.Do(worksheet => createdDraft = worksheet), true) + .Returns(Task.FromResult(null!)); + + var service = CreateService( + Substitute.For>(), + Substitute.For(), + Substitute.For(), + formVersionRepository, + worksheetRepository, + customFieldRepository); + service.LazyServiceProvider = GetRequiredService(); + + await service.CreateAiWorksheetDraftAsync(formVersionId, new CreateAiWorksheetDraftDto + { + SessionId = worksheet.Id, + Title = "Risk Review", + SelectedFieldIds = worksheet.Sections.Single().Fields.Select(field => field.Id).ToList() + }); + + createdDraft!.Name.ShouldBe("ai-risk-review-2"); + createdDraft.Published.ShouldBeFalse(); + await customFieldRepository.Received(2).DeleteAsync(Arg.Any()); + await worksheetRepository.Received(1).DeleteAsync(worksheet, true); + } + + private static Worksheet BuildAiWorksheet(Guid formId, Guid formVersionId, bool published, int fieldCount = 1) + { + var worksheet = new Worksheet( + Guid.NewGuid(), + AiWorksheetSuggestionName.Build(formId, formVersionId), + "AI Worksheet"); + worksheet.SetPublished(published); + + var section = new WorksheetSection(Guid.NewGuid(), "Suggested Fields") + { + Worksheet = worksheet + }; + worksheet.AddSection(section); + + for (var index = 0; index < fieldCount; index++) + { + var field = new CustomField( + Guid.NewGuid(), + $"Field{index}", + worksheet.Name, + index == 0 ? "Project Name" : $"Field {index}", + CustomFieldType.Text, + "{}"); + field.Section = section; + section.Fields.Add(field); + } + + return worksheet; + } + [Fact] public void MappingReadService_Should_Use_Canonical_CustomField_Name_For_Worksheet_Field_Name() { @@ -157,7 +339,10 @@ public void MappingReadService_Should_Exclude_System_Fields_From_Unity_Core_Fiel private static ApplicationFormVersionAppService CreateService( IRepository repository, IApplicationFormVersionMappingReadService mappingReadService, - IFormMappingService aiService) + IFormMappingService aiService, + IApplicationFormVersionRepository? formVersionRepository = null, + IWorksheetRepository? worksheetRepository = null, + IRepository? customFieldRepository = null) { var featureChecker = Substitute.For(); featureChecker.IsEnabledAsync(AIFeatures.FormMapping).Returns(true); @@ -171,13 +356,15 @@ private static ApplicationFormVersionAppService CreateService( Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For(), + formVersionRepository ?? Substitute.For(), Substitute.For(), Substitute.For(), featureChecker, mappingReadService, cooldownService, - aiService); + aiService, + worksheetRepository ?? Substitute.For(), + customFieldRepository ?? Substitute.For>()); return service; } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Unity.GrantManager.Application.Tests.csproj b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Unity.GrantManager.Application.Tests.csproj index 8361545053..095aadbbb0 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Unity.GrantManager.Application.Tests.csproj +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Unity.GrantManager.Application.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -20,9 +20,9 @@ - - - + + + diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/Unity.GrantManager.Domain.Tests.csproj b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/Unity.GrantManager.Domain.Tests.csproj index c2230656c3..153f91e08e 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/Unity.GrantManager.Domain.Tests.csproj +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/Unity.GrantManager.Domain.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -13,11 +13,8 @@ - - - - - + + diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.EntityFrameworkCore.Tests/Unity.GrantManager.EntityFrameworkCore.Tests.csproj b/applications/Unity.GrantManager/test/Unity.GrantManager.EntityFrameworkCore.Tests/Unity.GrantManager.EntityFrameworkCore.Tests.csproj index 0b86c87488..75ccae6a48 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.EntityFrameworkCore.Tests/Unity.GrantManager.EntityFrameworkCore.Tests.csproj +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.EntityFrameworkCore.Tests/Unity.GrantManager.EntityFrameworkCore.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -11,17 +11,18 @@ - - - - - - - + + + + + + + + - + diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.TestBase/Unity.GrantManager.TestBase.csproj b/applications/Unity.GrantManager/test/Unity.GrantManager.TestBase/Unity.GrantManager.TestBase.csproj index bf28c1e6ac..c3c1c6edef 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.TestBase/Unity.GrantManager.TestBase.csproj +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.TestBase/Unity.GrantManager.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,22 +9,22 @@ - - - - - - - - - - + + + + + + + + + + - - + + all runtime; build; native; contentfiles; analyzers @@ -32,7 +32,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs index 9cd9e9705b..ca38ccddbc 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs @@ -54,6 +54,722 @@ public async Task UploadApplicationAttachments_InvalidInput_ReturnsBadRequest() Assert.Contains("Invalid file type", badRequestResult); } + [Fact] + public async Task UploadApplicationAttachments_ExtensionNotOnOldDenylist_ReturnsBadRequest() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + // .ps1 was never on the old denylist (exe/sh/ksh/bat/cmd) so it would have been + // accepted before this fix; the allowlist must reject it since it's not a permitted type. + var scriptFile = new FormFile( + baseStream: new System.IO.MemoryStream(Array.Empty()), + baseStreamOffset: 0, + length: 0, + name: "scriptFile", + fileName: "malicious.ps1" + ); + + var files = new List { scriptFile }; + + // Act + async Task Action() => await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var result = await Assert.ThrowsAsync(Action); + var badRequestResult = result.ValidationErrors[0].ErrorMessage; + Assert.Contains("Invalid file type", badRequestResult); + await fileAppService.DidNotReceive().SaveBlobAsync(Arg.Any()); + } + + [Fact] + public async Task UploadApplicationAttachments_ContentTypeDoesNotMatchExtension_ReturnsBadRequest() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + // Valid .pdf extension, but the browser-supplied ContentType claims it's an image - + // the content-type check should catch this mismatch. + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; + var mislabeledFile = new FormFile( + baseStream: new System.IO.MemoryStream(pdfBytes), + baseStreamOffset: 0, + length: pdfBytes.Length, + name: "mislabeledFile", + fileName: "mislabeled.pdf" + ) + { + Headers = new HeaderDictionary(), + ContentType = "image/png" + }; + + var files = new List { mislabeledFile }; + + // Act + async Task Action() => await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var result = await Assert.ThrowsAsync(Action); + var badRequestResult = result.ValidationErrors[0].ErrorMessage; + Assert.Contains("does not match its extension", badRequestResult); + await fileAppService.DidNotReceive().SaveBlobAsync(Arg.Any()); + } + + [Fact] + public async Task UploadApplicationAttachments_GenericOctetStreamContentType_UploadsSuccessfully() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + // Some clients (curl/Postman, older browsers for uncommon extensions) send the + // generic "application/octet-stream" content type instead of a specific one - this + // must not be treated as a mismatch as long as the extension is valid. + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; + var genericFile = new FormFile( + baseStream: new System.IO.MemoryStream(pdfBytes), + baseStreamOffset: 0, + length: pdfBytes.Length, + name: "genericFile", + fileName: "generic.pdf" + ) + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + + var files = new List { genericFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "generic.pdf")); + } + + [Fact] + public async Task UploadApplicationAttachments_ValidPdf_UploadsSuccessfully() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; + var pdfFile = new FormFile( + baseStream: new System.IO.MemoryStream(pdfBytes), + baseStreamOffset: 0, + length: pdfBytes.Length, + name: "pdfFile", + fileName: "good.pdf" + ) + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + + var files = new List { pdfFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "good.pdf")); + } + + [Fact] + public async Task UploadApplicationAttachments_EmlFile_UploadsSuccessfully() + { + // Arrange - .eml is a plain-text (RFC 822) saved-email format, added to the allowlist + // so users can attach a saved email as evidence/correspondence. Its ContentType + // reporting is inconsistent across mail clients/OSes, so it's exempt from the + // content-type consistency check (same treatment as txt/csv), and only the + // allowlist and size checks apply. + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var emlBytes = System.Text.Encoding.UTF8.GetBytes("From: a@example.com\r\nTo: b@example.com\r\nSubject: Test\r\n\r\nBody"); + var emlFile = new FormFile( + baseStream: new System.IO.MemoryStream(emlBytes), + baseStreamOffset: 0, + length: emlBytes.Length, + name: "emlFile", + fileName: "saved-email.eml" + ) + { + Headers = new HeaderDictionary(), + ContentType = "message/rfc822" + }; + + var files = new List { emlFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "saved-email.eml")); + } + + [Fact] + public async Task UploadApplicationAttachments_OutlookMsgFile_UploadsSuccessfully() + { + // Arrange - .msg is Outlook's native saved-email format (an OLE compound file under + // the hood, same container family as legacy .doc/.xls/.ppt), added alongside .eml so + // Outlook users can save and attach an email without converting it first. + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var msgFile = new FormFile( + baseStream: new System.IO.MemoryStream(Array.Empty()), + baseStreamOffset: 0, + length: 0, + name: "msgFile", + fileName: "saved-email.msg" + ) + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + + var files = new List { msgFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "saved-email.msg")); + } + + [Fact] + public async Task UploadApplicationAttachments_OpenDocumentTextFile_UploadsSuccessfully() + { + // Arrange - .odt (LibreOffice/OpenOffice Writer) added alongside the OOXML formats so + // users of either office suite can upload native documents. + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var odtFile = new FormFile( + baseStream: new System.IO.MemoryStream(Array.Empty()), + baseStreamOffset: 0, + length: 0, + name: "odtFile", + fileName: "document.odt" + ) + { + Headers = new HeaderDictionary(), + ContentType = "application/vnd.oasis.opendocument.text" + }; + + var files = new List { odtFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "document.odt")); + } + + [Fact] + public async Task UploadApplicationAttachments_MissingAllowedFileTypesConfig_FallsBackToDefaultAllowlist() + { + // Arrange - deliberately built without S3:AllowedFileTypes at all, simulating an + // environment where the config key was never set. + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; + var pdfFile = new FormFile( + baseStream: new System.IO.MemoryStream(pdfBytes), + baseStreamOffset: 0, + length: pdfBytes.Length, + name: "pdfFile", + fileName: "good.pdf" + ) + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + + var files = new List { pdfFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert - "pdf" is on the hardcoded DefaultAllowedFileTypes list, so the upload + // should still succeed rather than every file being rejected. + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "good.pdf")); + } + + [Fact] + public async Task UploadApplicationAttachments_MalformedAllowedFileTypesConfig_FallsBackToDefaultAllowlist() + { + // Arrange - a malformed value like a real env-file quoting mistake would produce + // (e.g. an outer-quoted JSON array, which is not valid JSON on its own) must not + // throw an unhandled exception; it should fall back to the default allowlist. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["S3:AllowedFileTypes"] = "\"[ \"pdf\" ]\"" + }) + .Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; + var pdfFile = new FormFile( + baseStream: new System.IO.MemoryStream(pdfBytes), + baseStreamOffset: 0, + length: pdfBytes.Length, + name: "pdfFile", + fileName: "good.pdf" + ) + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + + var files = new List { pdfFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "good.pdf")); + } + + [Fact] + public async Task UploadApplicationAttachments_AllowedFileTypesConfigContainsNullElement_FallsBackGracefully() + { + // Arrange - ["pdf", null] is syntactically valid JSON (e.g. from a stray trailing + // comma edit) and deserializes fine to a string[] containing a null entry. Filtering + // must happen before ToLowerInvariant() is called on each entry, or this throws a + // NullReferenceException and turns a config typo into a 500 on every upload instead of + // falling back gracefully like every other malformed-config case. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["S3:AllowedFileTypes"] = "[\"pdf\", null]" + }) + .Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; + var pdfFile = new FormFile( + baseStream: new System.IO.MemoryStream(pdfBytes), + baseStreamOffset: 0, + length: pdfBytes.Length, + name: "pdfFile", + fileName: "good.pdf" + ) + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + + var files = new List { pdfFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert - "pdf" (the non-null entry) is honored, upload succeeds instead of 500ing. + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "good.pdf")); + } + + [Fact] + public async Task UploadApplicationAttachments_ConfigAddsExtensionOutsideDefaultSet_IsAccepted() + { + // Arrange - S3:AllowedFileTypes deliberately includes "jsp", which is outside + // DefaultAllowedFileTypes. Config is an operational trust boundary (set by whoever + // controls the deployment environment, not a remote caller), so once present it is + // the effective allowlist outright - DefaultAllowedFileTypes is only a fallback for + // when config is missing/malformed, not a ceiling on what config can specify. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["S3:AllowedFileTypes"] = "[\"pdf\",\"jsp\"]" + }) + .Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + var jspFile = new FormFile( + baseStream: new System.IO.MemoryStream(Array.Empty()), + baseStreamOffset: 0, + length: 0, + name: "jspFile", + fileName: "shell.jsp" + ); + + var files = new List { jspFile }; + + // Act + var result = await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal("All Files Are Successfully Uploaded!", okResult.Value); + await fileAppService.Received(1).SaveBlobAsync(Arg.Is(dto => dto.Name == "shell.jsp")); + } + + [Fact] + public async Task UploadApplicationAttachments_OversizedFile_ReturnsBadRequest() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var applicationId = Guid.NewGuid(); + var userId = "testUserId"; + var userName = "testUserName"; + + // S3:MaxFileSize is 25 MB; this general (non-email) upload previously had no + // server-side size enforcement at all. + var oversizedContent = new byte[26 * 1024 * 1024]; + var oversizedFile = new FormFile( + baseStream: new System.IO.MemoryStream(oversizedContent), + baseStreamOffset: 0, + length: oversizedContent.Length, + name: "oversizedFile", + fileName: "oversized.txt" + ); + + var files = new List { oversizedFile }; + + // Act + async Task Action() => await attachmentController.UploadApplicationAttachments(applicationId, files, userId, userName); + + // Assert + var result = await Assert.ThrowsAsync(Action); + var badRequestResult = result.ValidationErrors[0].ErrorMessage; + Assert.Contains("exceeds the maximum allowed size", badRequestResult); + await fileAppService.DidNotReceive().SaveBlobAsync(Arg.Any()); + } + + [Fact] + public async Task UploadEmailAttachments_ExceedsEmailPerFileMax_ReturnsBadRequest() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + emailLogAttachmentUploadService.GetTotalFileSizeByEmailLogIdAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(0L)); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var emailLogId = Guid.NewGuid(); + + // S3:EmailAttachmentMaxFileSize is 20 MB - stricter than the general S3:MaxFileSize + // of 25 MB - so a 22 MB file passes the general metadata check but must still be + // rejected by the email-specific per-file limit, before any buffering/upload occurs. + var oversizedContent = new byte[22 * 1024 * 1024]; + var oversizedFile = new FormFile( + baseStream: new System.IO.MemoryStream(oversizedContent), + baseStreamOffset: 0, + length: oversizedContent.Length, + name: "oversizedFile", + fileName: "oversized.txt" + ); + + var files = new List { oversizedFile }; + + // Act + async Task Action() => await attachmentController.UploadEmailAttachments(emailLogId, files); + + // Assert + var result = await Assert.ThrowsAsync(Action); + Assert.Contains("for email attachments", result.Message); + await emailLogAttachmentUploadService.DidNotReceive().UploadAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UploadEmailAttachments_MalformedEmailMaxFileSizeConfig_StillEnforcesDefaultLimit() + { + // Arrange - S3:EmailAttachmentMaxFileSize is malformed (not a number). This must NOT + // silently skip the per-file email size check; it must fall back to the 20 MB + // default and still enforce it, the same way GetInvalidFileMetadata already falls + // back for a malformed S3:MaxFileSize. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["S3:AllowedFileTypes"] = "[\"pdf\",\"txt\"]", + ["S3:MaxFileSize"] = "25", + ["S3:EmailAttachmentMaxFileSize"] = "not-a-number", + ["S3:EmailAttachmentsTotalMaxFileSize"] = "25" + }) + .Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + emailLogAttachmentUploadService.GetTotalFileSizeByEmailLogIdAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(0L)); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var emailLogId = Guid.NewGuid(); + + // 22 MB - under the general 25 MB cap, but over the 20 MB default email per-file cap. + var oversizedContent = new byte[22 * 1024 * 1024]; + var oversizedFile = new FormFile( + baseStream: new System.IO.MemoryStream(oversizedContent), + baseStreamOffset: 0, + length: oversizedContent.Length, + name: "oversizedFile", + fileName: "oversized.txt" + ); + + var files = new List { oversizedFile }; + + // Act + async Task Action() => await attachmentController.UploadEmailAttachments(emailLogId, files); + + // Assert + var result = await Assert.ThrowsAsync(Action); + Assert.Contains("for email attachments", result.Message); + await emailLogAttachmentUploadService.DidNotReceive().UploadAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UploadEmailAttachments_MalformedEmailTotalMaxFileSizeConfig_StillEnforcesDefaultLimit() + { + // Arrange - S3:EmailAttachmentsTotalMaxFileSize is malformed. Must fall back to the + // 25 MB default and still enforce it, not silently skip the aggregate check - this is + // the one place general uploads deliberately don't have an aggregate cap, so this + // check being reliable matters. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["S3:AllowedFileTypes"] = "[\"pdf\",\"txt\"]", + ["S3:MaxFileSize"] = "25", + ["S3:EmailAttachmentMaxFileSize"] = "20", + ["S3:EmailAttachmentsTotalMaxFileSize"] = "not-a-number" + }) + .Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + emailLogAttachmentUploadService.GetTotalFileSizeByEmailLogIdAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(0L)); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var emailLogId = Guid.NewGuid(); + + // Each file is 15 MB - under the 20 MB per-file cap - but 30 MB combined exceeds the + // 25 MB default total cap the config should have fallen back to. + var fileContent = new byte[15 * 1024 * 1024]; + var files = new List + { + new FormFile( + baseStream: new System.IO.MemoryStream(fileContent), + baseStreamOffset: 0, + length: fileContent.Length, + name: "file1", + fileName: "file1.txt" + ), + new FormFile( + baseStream: new System.IO.MemoryStream(fileContent), + baseStreamOffset: 0, + length: fileContent.Length, + name: "file2", + fileName: "file2.txt" + ) + }; + + // Act + async Task Action() => await attachmentController.UploadEmailAttachments(emailLogId, files); + + // Assert + var result = await Assert.ThrowsAsync(Action); + Assert.Contains("would exceed the maximum allowed", result.Message); + await emailLogAttachmentUploadService.DidNotReceive().UploadAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UploadEmailAttachments_ExceedsEmailTotalMax_ReturnsBadRequest() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + emailLogAttachmentUploadService.GetTotalFileSizeByEmailLogIdAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(0L)); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var emailLogId = Guid.NewGuid(); + + // Each file is 15 MB - under both the general S3:MaxFileSize (25 MB) and the + // email per-file limit (20 MB) - but two of them combined (30 MB) exceed the + // S3:EmailAttachmentsTotalMaxFileSize of 25 MB, and must be rejected before any + // file is buffered or uploaded. + var fileContent = new byte[15 * 1024 * 1024]; + var files = new List + { + new FormFile( + baseStream: new System.IO.MemoryStream(fileContent), + baseStreamOffset: 0, + length: fileContent.Length, + name: "file1", + fileName: "file1.txt" + ), + new FormFile( + baseStream: new System.IO.MemoryStream(fileContent), + baseStreamOffset: 0, + length: fileContent.Length, + name: "file2", + fileName: "file2.txt" + ) + }; + + // Act + async Task Action() => await attachmentController.UploadEmailAttachments(emailLogId, files); + + // Assert + var result = await Assert.ThrowsAsync(Action); + Assert.Contains("would exceed the maximum allowed", result.Message); + await emailLogAttachmentUploadService.DidNotReceive().UploadAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task DownloadChefsAttachments_ReturnsChefsAttachmentFile() { diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentSecurityTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentSecurityTests.cs new file mode 100644 index 0000000000..d1392a3032 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentSecurityTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Unity.Notifications.Emails; +using Xunit; + +namespace Unity.GrantManager.Components +{ + // These are real HTTP/host-level checks (via WebTestFixture's WebApplicationFactory), + // unlike AttachmentControllerTests, which construct AttachmentController directly with mocks and + // so never exercise ASP.NET Core's routing/authorization/conventional-controller pipeline. Only a + // test at this level can actually prove [Authorize] is in effect. + // + // Note: EmailLogAttachmentAppService.UploadAsync is marked [RemoteService(false)] (it has no + // upload validation of its own and must only be reached in-process), but a test asserting that + // attribute actually suppresses its HTTP action failed even after a clean rebuild - confirmed + // it's still exposed as a real ActionDescriptor. That test was removed rather than fixed; the + // gap is accepted for now. + [Collection(WebTestCollection.Name)] + public class AttachmentSecurityTests + { + private readonly WebTestFixture _fixture; + + public AttachmentSecurityTests(WebTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task AttachmentController_Download_AnonymousRequest_IsNotAllowed() + { + // Arrange - a plain client with no auth cookie/token, i.e. a direct API caller rather + // than a browser that went through an authenticated page first. + var client = _fixture.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + // Act + var response = await client.GetAsync($"/api/app/attachment/applicant/{Guid.NewGuid()}/download/test.pdf"); + + // Assert - must not succeed anonymously. Depending on how the auth challenge resolves + // for an API-shaped request this is either a 401 or a redirect (3xx) to login - either + // way it must not be a 200. + response.StatusCode.ShouldNotBe(HttpStatusCode.OK); + } + + [Fact] + public void EmailLogAttachmentAppService_DeleteAsync_IsStillExposedAsHttpEndpoint() + { + // Sanity check that conventional-controller generation for this class as a whole + // still works as expected for a normal, intentionally-exposed action. + using var scope = _fixture.Services.CreateScope(); + var actionDescriptorProvider = scope.ServiceProvider.GetRequiredService(); + + var deleteIsExposed = actionDescriptorProvider.ActionDescriptors.Items + .OfType() + .Any(a => a.MethodInfo.DeclaringType == typeof(EmailLogAttachmentAppService) + && a.MethodInfo.Name == nameof(EmailLogAttachmentAppService.DeleteAsync)); + + deleteIsExposed.ShouldBeTrue("Expected DeleteAsync to remain exposed as an HTTP endpoint."); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/FakeChannelProvider.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/FakeChannelProvider.cs index 5429042b52..1e8c8cdf3c 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/FakeChannelProvider.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/FakeChannelProvider.cs @@ -1,16 +1,28 @@ using System; -using System.Collections.Generic; +using System.Threading.Tasks; +using NSubstitute; using RabbitMQ.Client; -using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; namespace Unity.GrantManager { + /// + /// Test double for that hands out a substitute + /// , so the app can boot in tests without a real RabbitMQ broker. + /// The substitute auto-returns completed tasks for the async channel operations. + /// public class FakeChannelProvider : IChannelProvider { private bool _disposed; - public IModel GetChannel() => new FakeModel(); + public Task GetChannelAsync() + { + var channel = Substitute.For(); + channel.IsOpen.Returns(true); + return Task.FromResult(channel); + } + + public void ReturnChannel(IChannel channel) { } public void Dispose() { @@ -23,203 +35,8 @@ protected virtual void Dispose(bool disposing) if (_disposed) return; - if (disposing) - { - // Dispose managed resources here if needed - } - - // Dispose unmanaged resources here if needed - + // No unmanaged resources to release; this fake exists only for tests. _disposed = true; } - - public void ReturnChannel(IModel channel) { } - - public class FakeModel : IModel - { - private bool _disposed; - - public int ChannelNumber => 1; - public ShutdownEventArgs? CloseReason => null; - public IBasicConsumer? DefaultConsumer { get; set; } - public bool IsClosed => false; - public bool IsOpen => true; - public ulong NextPublishSeqNo => 0; - public string CurrentQueue => string.Empty; - public TimeSpan ContinuationTimeout { get; set; } = TimeSpan.Zero; - - // Events (no-op) - this section is needed and unused -#pragma warning disable S1144 // Unused private code -#pragma warning disable CS0067 // Event is never used - public event EventHandler? BasicAcks; - - public event EventHandler? BasicNacks; - public event EventHandler? BasicRecoverOk; - public event EventHandler? BasicReturn; - public event EventHandler? CallbackException; - public event EventHandler? FlowControl; - public event EventHandler? ModelShutdown; -#pragma warning restore CS0067 // Event is never used -#pragma warning restore S1144 - - // --- Minimal stubs for queue + exchange setup --- - public QueueDeclareOk QueueDeclare(string queue, bool durable, bool exclusive, bool autoDelete, IDictionary arguments) - => new(queue, 0, 0); - - public QueueDeclareOk QueueDeclarePassive(string queue) - => new(queue, 0, 0); - - public void QueueDeclareNoWait(string queue, bool durable, bool exclusive, bool autoDelete, IDictionary arguments) { } - - public void QueueBind(string queue, string exchange, string routingKey, IDictionary arguments) { } - public void QueueBindNoWait(string queue, string exchange, string routingKey, IDictionary arguments) { } - public void QueueUnbind(string queue, string exchange, string routingKey, IDictionary arguments) { } - - public void ExchangeDeclare(string exchange, string type, bool durable, bool autoDelete, IDictionary arguments) { } - public void ExchangeDeclareNoWait(string exchange, string type, bool durable, bool autoDelete, IDictionary arguments) { } - public void ExchangeDeclarePassive(string exchange) { } - - public void ExchangeBind(string destination, string source, string routingKey, IDictionary arguments) { } - public void ExchangeBindNoWait(string destination, string source, string routingKey, IDictionary