diff --git a/applications/Unity.GrantManager/docker-compose.yml b/applications/Unity.GrantManager/docker-compose.yml index a3015c37e3..720f9574d6 100644 --- a/applications/Unity.GrantManager/docker-compose.yml +++ b/applications/Unity.GrantManager/docker-compose.yml @@ -146,6 +146,27 @@ services: networks: - common-network + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./scripts/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./scripts/prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml:ro + depends_on: + - unity-grantmanager-web + networks: + - common-network + + alertmanager: + image: prom/alertmanager:latest + ports: + - "9093:9093" + volumes: + - ./scripts/prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + networks: + - common-network + volumes: postgres_data: redis_volume_data: diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md new file mode 100644 index 0000000000..8b8537d6b0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md @@ -0,0 +1,14 @@ +# Unity.AI Docs + +## Architecture +- [`index.md`](./index.md) +- [`flow-map.md`](./flow-map.md) +- [`prompt-map.md`](./prompt-map.md) +- [`implementation-playbook.md`](./implementation-playbook.md) + +## Operations +- [`operations/application-analysis.md`](./operations/application-analysis.md) +- [`operations/attachment-summary.md`](./operations/attachment-summary.md) +- [`operations/application-scoring.md`](./operations/application-scoring.md) +- [`operations/form-mapping.md`](./operations/form-mapping.md) +- [`operations/form-worksheet.md`](./operations/form-worksheet.md) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md new file mode 100644 index 0000000000..42bd018836 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md @@ -0,0 +1,14 @@ +# Flow Map + +## Standard path +UI -> API app service -> queue -> background job -> AI runtime -> persisted result + +## Operation families +- Application Analysis: submission -> analysis +- Attachment Summary: attachment ids -> summaries +- Application Scoring: application + scoresheet -> scoring +- Form Mapping: form version -> mapping +- Form Worksheet: form version -> worksheet + +## Build Rule +See [`implementation-playbook.md`](./implementation-playbook.md) for the canonical add-a-new-operation sequence. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md new file mode 100644 index 0000000000..9a9a50a207 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md @@ -0,0 +1,73 @@ +# AI Operation Implementation Playbook + +## Purpose +Use this when adding a new AI operation. Start with the bare minimum and only add optional pieces when the operation needs them. + +Use these existing operations as the canonical references: + +1. `ApplicationAnalysis` +2. `ApplicationScoring` +3. `AttachmentSummary` +4. `FormMapping` +5. `FormWorksheet` + +## Base Pattern +1. Define the prompt type. +2. Add the v2 prompt seed. +3. Add the operation seed. +4. Add the runtime contract method. +5. Add the runtime implementation. +6. Add the app service or queue entry. +7. Add the background job only if the result must be applied or persisted. +8. Add the UI button and status polling only if users trigger the operation from the web app. +9. Add tests for the prompt, runtime parsing, and job or service path. + +## Bare Minimum +For the first pass, only add what is required for a working operation: + +- prompt type +- prompt seed +- operation seed +- runtime method +- queue/app service entry +- job or direct apply path, if needed + +## Optional Pieces +Add these only when the operation needs them: + +- feature flag +- permissions +- permission definition provider entries +- menu entry +- UI button +- status polling +- refresh-after-complete behavior +- persistence/import/publish/assign behavior + +## Rules +- Keep the prompt as the source of truth. +- Reuse the existing async generation pattern. +- Do not hardcode field buckets or response shapes in UI code. +- Do not invent new plumbing if an existing operation already does the same job. +- Do not add tenant feature seeding. +- Do not add write-back UI behavior unless the operation already persists output. + +## Expected Flow +1. User clicks Generate. +2. UI disables the button and shows generating state, if the operation has UI. +3. API checks permission and feature flag, if the operation uses them. +4. API queues the generation request. +5. Background job loads the operation context. +6. Job builds the prompt payload from existing data. +7. AI runtime renders v2 prompts and logs input/output. +8. Job parses the AI response. +9. Job applies the result if needed. +10. Job stamps status and rate limit state. +11. UI polls status and refreshes after completion, if applicable. + +## Validation +- Confirm the prompt version is v2. +- Confirm the operation exists in the AI operation seed. +- Confirm any required feature flag exists in the host feature definitions. +- Confirm any required permission is wired in the permission definition provider. +- Confirm the UI button uses the same generating/status flow as the other operations, if it is user-triggered. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md new file mode 100644 index 0000000000..768b23b3c2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md @@ -0,0 +1,80 @@ +# Unity.AI Index + +## Domain.Shared +AI constants: +- feature flags +- permission names +- localization keys +- prompt type names + +## Application.Contracts +Public AI surface: +- app service interfaces +- queue interfaces +- DTOs +- permission definitions + +## Application +AI implementation: +- runtime +- prompt seeding +- generation app services +- validators +- prompt logging + +## Web +UI-facing AI bits: +- menus +- generation buttons +- status polling + +## Files +### Application +- `AI/Operations` - validators and helpers +- `AI/Runtime` - rendering, parsing, logging, provider calls +- `AI/Prompts` - prompt types and template plumbing +- `DataSeed` - seeded prompt and operation data +- `Generation/AIGenerationAppService.cs` - generation API + +### Application.Contracts +- `AI/IAIService.cs` - runtime contract +- `Generation/IAIGenerationAppService.cs` - generation app service contract +- `Generation/*ResultDto.cs` - queued result DTOs +- `AI/Operations/IAIGenerationPrerequisiteValidator.cs` - queue prerequisites +- `Automation/IApplicationAIGenerationQueue.cs` - queue contract +- `Permissions/*` - permissions + +### Domain.Shared +- `Features/AIFeatures.cs` - feature flags +- `Localization/AILocalizationKeys.cs` - messages +- `PromptTypes/AIPromptTypes.cs` - prompt family names + +### Web +- `Menus/AIMenuContributor.cs` - menu entries +- `Menus/AIMenus.cs` - menu item names + +## Access +| Operation | View | Generate | +| --- | --- | --- | +| Application Analysis | `ViewApplicationAnalysis` | `GenerateApplicationAnalysis` | +| Attachment Summary | `ViewAttachmentSummary` | `GenerateAttachmentSummaries` | +| Application Scoring | `ViewScoringResult` | `GenerateScoring` | +| Form Mapping | `ViewFormMapping` | `GenerateFormMapping` | +| Form Worksheet | `ViewFormWorksheet` | `GenerateFormWorksheet` | + +- Features: + - `Unity.AI.ApplicationAnalysis` + - `Unity.AI.AttachmentSummaries` + - `Unity.AI.Scoring` + - `Unity.AI.FormMapping` + - `Unity.AI.FormWorksheet` + +- Rule: + - Both permission and feature gate must allow generation. + +## AI Notes +- Prompt logging: logs rendered system/user prompts and provider output. +- Response parsing: parses provider output into stable app-facing results. +- Feature gating: disabled features fail early at the API boundary. +- Background jobs: mark failures, then re-throw. +- New operation playbook: see `implementation-playbook.md`. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md new file mode 100644 index 0000000000..1b2bfdb194 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md @@ -0,0 +1,19 @@ +# Application Analysis + +## Goal +Generate an AI analysis of an application submission. + +## Inputs +- Application submission data +- Application context +- Optional attachments, when present + +## Surface +- `POST /api/app/ai/generation/application-analysis` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured analysis output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- This is a reviewer-oriented summary and recommendation flow. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md new file mode 100644 index 0000000000..a19e1f8bb1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md @@ -0,0 +1,20 @@ +# Application Scoring + +## Goal +Generate scored answers for a submitted application against an assigned scoresheet. + +## Inputs +- Application submission data +- Assigned scoresheet +- Scoresheet questions and definitions + +## Surface +- `POST /api/app/ai/generation/application-scoring` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured scoring output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- The prompt asks for answers only for the configured section or scoresheet context. +- The parsed output must align with the scoresheet question ids. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md new file mode 100644 index 0000000000..5735c3996f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md @@ -0,0 +1,18 @@ +# Attachment Summary + +## Goal +Generate summaries for selected application attachments. + +## Inputs +- One or more attachment IDs +- Application context + +## Surface +- `POST /api/app/ai/generation/attachment-summary` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured attachment summary output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- Each attachment is processed as part of the generation request. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md new file mode 100644 index 0000000000..2881cefe9f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md @@ -0,0 +1,32 @@ +# Form Mapping + +## Goal +Generate recommended CHEFS-to-Unity field mapping for a form version. + +## Inputs +- CHEFS fields from the form version +- Unity core intake fields +- Worksheet-derived custom fields when available + +## Rule +- Prefer existing Unity core intake fields where they already fit the source field. +- Only suggest worksheet fields or worksheet creation when the form genuinely needs them. + +## Surface +- `POST /api/app/ai/generation/form-mapping` +- `GET /api/app/ai/generation/status` +- `GET /api/app/application-form-version/{id}` + +## Contract +- Structured mapping recommendation JSON output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Output Shape +- Core field matches. +- Worksheet field matches. +- Worksheet creation suggestions. +- Issues or conflicts. +- Keep the result valid JSON and compatible with the mapping page flow. + +## Notes +- The AI response is expected to stay structured and JSON-shaped. +- For new operations, follow [`implementation-playbook.md`](../implementation-playbook.md). diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md new file mode 100644 index 0000000000..49d76dd9df --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md @@ -0,0 +1,29 @@ +# Form Worksheet + +## Goal +Generate a recommended worksheet definition for a form version. + +## Inputs +- Form version context +- Form name +- Existing worksheet links +- Worksheet field context + +## Rule +- Prefer existing Unity core fields when they already fit the need. +- Only add new worksheet fields when the form genuinely needs extra Unity fields. + +## Surface +- `POST /api/app/ai/generation/form-worksheet` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured Flex worksheet JSON output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Output Shape +- Full worksheet definition JSON. +- Include only additional worksheet fields that the form needs beyond core Unity fields. +- Keep the result valid JSON and compatible with Flex import. + +## Notes +- The AI output should stay valid JSON. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md new file mode 100644 index 0000000000..c7d544c1f1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md @@ -0,0 +1,22 @@ +# Prompt Map + +## Prompt families +- `ApplicationAnalysis` - review and recommendation +- `AttachmentSummary` - attachment summary +- `ApplicationScoring` - question scoring +- `FormMapping` - CHEFS to Unity mapping +- `FormWorksheet` - worksheet generation + +## Versions +- `v0`, `v1`, `v2` live under `AI/Prompts/Versions` +- The seeder loads built-in prompt rows from those versions +- Runtime selects by prompt family and version + +## Prompt rules +- Versioned prompts are the source of truth. +- Prompt templates define the request shape. +- Structured outputs should stay JSON-shaped. +- New versions should not silently change behavior. + +## Build Rule +Use [`implementation-playbook.md`](./implementation-playbook.md) when adding a new prompt-backed operation. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 8caf69258d..299f781c80 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -10,8 +10,9 @@ public interface IAIService Task IsAvailableAsync(); Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request, CancellationToken cancellationToken = default); - Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); + Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default); + Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs index 67df324548..55d4feb1df 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs @@ -10,4 +10,8 @@ public interface IAIGenerationPrerequisiteValidator Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId); Task EnsureApplicationScoringAvailableAsync(Guid applicationId); + + Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId); + + Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs deleted file mode 100644 index 031acd1937..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Unity.AI.Requests; - -public sealed class AttachmentSummaryBatchRequest -{ - [JsonPropertyName("attachments")] - public List Attachments { get; set; } = []; - - [JsonPropertyName("promptVersion")] - public string? PromptVersion { get; set; } -} - -public sealed class AttachmentSummaryBatchItemRequest -{ - [JsonPropertyName("attachmentId")] - public string AttachmentId { get; set; } = string.Empty; - - [JsonPropertyName("fileName")] - public string FileName { get; set; } = string.Empty; - - [JsonPropertyName("contentType")] - public string ContentType { get; set; } = "application/octet-stream"; - - [JsonPropertyName("extractedText")] - public string? ExtractedText { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs new file mode 100644 index 0000000000..caed63764c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormMappingRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs new file mode 100644 index 0000000000..290445b9a3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormWorksheetRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs deleted file mode 100644 index 4751fe9122..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Unity.AI.Responses; - -public sealed class AttachmentSummaryBatchResponse -{ - [JsonPropertyName("attachments")] - public List Attachments { get; set; } = []; -} - -public sealed class AttachmentSummaryBatchItemResponse -{ - [JsonPropertyName("attachmentId")] - public string AttachmentId { get; set; } = string.Empty; - - [JsonPropertyName("summary")] - public string Summary { get; set; } = string.Empty; -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs new file mode 100644 index 0000000000..2ed0538b8a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("isCustom")] + public bool IsCustom { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs new file mode 100644 index 0000000000..faa0e5eddb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingIssueResponse +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs new file mode 100644 index 0000000000..d86116d2e0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingMatchResponse +{ + [JsonPropertyName("sourceField")] + public string SourceField { get; set; } = string.Empty; + + [JsonPropertyName("targetField")] + public string TargetField { get; set; } = string.Empty; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonPropertyName("confidence")] + public decimal Confidence { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs new file mode 100644 index 0000000000..4015265699 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingResponse +{ + public string Mapping { get; set; } = string.Empty; + + [JsonPropertyName("coreFieldMatches")] + public List CoreFieldMatches { get; set; } = []; + + [JsonPropertyName("worksheetMatches")] + public List WorksheetMatches { get; set; } = []; + + [JsonPropertyName("worksheetCreationSuggestions")] + public List WorksheetCreationSuggestions { get; set; } = []; + + [JsonPropertyName("issues")] + public List Issues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs new file mode 100644 index 0000000000..4d5e207b5e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingWorksheetResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("fieldMatches")] + public List FieldMatches { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs new file mode 100644 index 0000000000..9ecc8f3ac1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormWorksheetCreationResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("suggestedFields")] + public List SuggestedFields { get; set; } = []; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} 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 new file mode 100644 index 0000000000..4fea2802d2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs @@ -0,0 +1,69 @@ +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/Automation/IApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs deleted file mode 100644 index e7c0dbbb6c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Unity.AI.Automation; - -public interface IApplicationAIGenerationQueue -{ - Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null); - Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); - Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); - Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs deleted file mode 100644 index 3a832df03c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Unity.AI.Generation; - -public class AIGenerationRequestDto -{ - public Guid ApplicationId { get; set; } - - public Guid OperationId { get; set; } - - public string OperationType { get; set; } = string.Empty; - - public string Status { get; set; } = string.Empty; -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs index ca5684257c..11729873af 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs @@ -1,12 +1,47 @@ +using System; + namespace Unity.AI.Generation; public class AIGenerationStatusDto { - public AIGenerationStatusRequestDto? GenerationRequest { get; set; } + public AIGenerationRequestDto? GenerationRequest { get; set; } + + public Guid Id { get; set; } + + public Guid? ApplicationId { get; set; } + + public Guid? OperationId { get; set; } + + public string OperationType { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public DateTime? StartedAt { get; set; } + + public DateTime? CompletedAt { get; set; } public string? FailureReason { get; set; } - public bool IsGenerating { get; set; } + public bool IsActive { get; set; } +} + +public class AIGenerationRequestDto +{ + public Guid Id { get; set; } + + public Guid? ApplicationId { get; set; } + + public Guid? OperationId { get; set; } + + public string OperationType { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public DateTime? StartedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + public string? FailureReason { get; set; } - public int RetryAfterSeconds { get; set; } + public bool IsActive { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs deleted file mode 100644 index b80e09f433..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace Unity.AI.Generation; - -public class AIGenerationStatusRequestDto -{ - public Guid Id { get; set; } - - public Guid? ApplicationId { get; set; } - - public Guid? OperationId { get; set; } - - public string OperationType { get; set; } = string.Empty; - - public string Status { get; set; } = string.Empty; - - public DateTime? StartedAt { get; set; } - - public DateTime? CompletedAt { get; set; } - - public string? FailureReason { get; set; } - - public bool IsActive { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AttachmentSummaryGenerationRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AttachmentSummaryGenerationRequestDto.cs new file mode 100644 index 0000000000..3fd80ac1fe --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AttachmentSummaryGenerationRequestDto.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Unity.AI.Generation; + +public class AttachmentSummaryGenerationRequestDto +{ + [Required] + [JsonPropertyName("applicationId")] + public Guid ApplicationId { get; set; } + + [Required] + [JsonPropertyName("attachmentIds")] + public List AttachmentIds { get; set; } = []; + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs deleted file mode 100644 index 82956d14de..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Unity.AI.Generation; - -public class GenerateAttachmentSummariesInputDto -{ - public Guid ApplicationId { get; set; } - - public List AttachmentIds { get; set; } = []; - - public string? PromptVersion { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs index c22118ed39..2823193cb0 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs @@ -1,19 +1,20 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; -using Unity.GrantManager.Attachments; -using Unity.GrantManager.GrantApplications; using Volo.Abp.Application.Services; namespace Unity.AI.Generation; public interface IAIGenerationAppService : IApplicationService { - Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input); + Task GenerateApplicationAttachmentSummariesAsync(AttachmentSummaryGenerationRequestDto request); - Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); - Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + + Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + + Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); Task GetStatusAsync(Guid applicationId, string operationType); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs index 3e4a2a7b9d..44b4101343 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -55,6 +55,26 @@ public override void Define(IPermissionDefinitionContext context) L("Permission:AI.GenerateScoring")) .RequireFeatures("Unity.AI.Scoring"); + var viewFormMapping = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormMapping, + L("Permission:AI.ViewFormMapping")) + .RequireFeatures("Unity.AI.FormMapping"); + + viewFormMapping.AddChild( + AIPermissions.Analysis.GenerateFormMapping, + L("Permission:AI.GenerateFormMapping")) + .RequireFeatures("Unity.AI.FormMapping"); + + var viewFormWorksheet = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormWorksheet, + L("Permission:AI.ViewFormWorksheet")) + .RequireFeatures("Unity.AI.FormWorksheet"); + + viewFormWorksheet.AddChild( + AIPermissions.Analysis.GenerateFormWorksheet, + L("Permission:AI.GenerateFormWorksheet")) + .RequireFeatures("Unity.AI.FormWorksheet"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); var configureAI = settingManagement.AddPermission( AIPermissions.Configuration.ConfigureAI, @@ -62,7 +82,9 @@ public override void Define(IPermissionDefinitionContext context) configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( "Unity.AI.Scoring", "Unity.AI.AttachmentSummaries", - "Unity.AI.ApplicationAnalysis")); + "Unity.AI.ApplicationAnalysis", + "Unity.AI.FormMapping", + "Unity.AI.FormWorksheet")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs index b9ea59607f..27dc36f649 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -17,10 +17,44 @@ public static class Analysis public const string ViewApplicationAnalysis = GroupName + ".ViewApplicationAnalysis"; public const string ViewAttachmentSummary = GroupName + ".ViewAttachmentSummary"; public const string ViewScoringResult = GroupName + ".ViewScoringResult"; + public const string ViewFormMapping = GroupName + ".ViewFormMapping"; + public const string ViewFormWorksheet = GroupName + ".ViewFormWorksheet"; public const string GenerateApplicationAnalysis = GroupName + ".GenerateApplicationAnalysis"; public const string GenerateAttachmentSummaries = GroupName + ".GenerateAttachmentSummaries"; public const string GenerateScoring = GroupName + ".GenerateScoring"; + public const string GenerateFormMapping = GroupName + ".GenerateFormMapping"; + public const string GenerateFormWorksheet = GroupName + ".GenerateFormWorksheet"; + } + + public static class ApplicationAnalysis + { + public const string View = Analysis.ViewApplicationAnalysis; + public const string Generate = Analysis.GenerateApplicationAnalysis; + } + + public static class AttachmentSummaries + { + public const string View = Analysis.ViewAttachmentSummary; + public const string Generate = Analysis.GenerateAttachmentSummaries; + } + + public static class ApplicationScoring + { + public const string View = Analysis.ViewScoringResult; + public const string Generate = Analysis.GenerateScoring; + } + + public static class FormMapping + { + public const string View = Analysis.ViewFormMapping; + public const string Generate = Analysis.GenerateFormMapping; + } + + public static class FormWorksheet + { + public const string View = Analysis.ViewFormWorksheet; + public const string Generate = Analysis.GenerateFormWorksheet; } public static class Configuration diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs index d7fae9b818..4a43caf10e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs @@ -15,6 +15,8 @@ public class AIExecutionModeResolver(IConfiguration configuration) : ITransientD { public const string AttachmentSummaryOperation = AIPromptTypes.AttachmentSummary; public const string ApplicationScoringOperation = AIPromptTypes.ApplicationScoring; + public const string FormMappingOperation = AIPromptTypes.FormMapping; + public const string FormWorksheetOperation = AIPromptTypes.FormWorksheet; public AIExecutionMode ResolveMode(string operationName) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs index eb882c8cae..4c8e58a2a6 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -2,19 +2,31 @@ using System; using System.Linq; using System.Threading.Tasks; +using Unity.Flex.Domain.Scoresheets; using Unity.AI.Localization; +using Unity.GrantManager.Applications; +using Unity.Modules.Shared.Correlation; using Volo.Abp; using Volo.Abp.DependencyInjection; +using Volo.Abp.Linq; namespace Unity.AI.Operations; public class AIGenerationPrerequisiteValidator( - IAIApplicationInputDataProvider dataProvider, + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IScoresheetRepository scoresheetRepository, + IAsyncQueryableExecuter asyncExecuter, IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency { public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) { - if (!await dataProvider.HasAttachmentsAsync(applicationId)) + var attachmentQuery = await applicationChefsFileAttachmentRepository.GetQueryableAsync(); + var hasAttachments = await asyncExecuter.AnyAsync(attachmentQuery.Where(a => a.ApplicationId == applicationId)); + if (!hasAttachments) { throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]); } @@ -22,7 +34,8 @@ public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) { - if (!await dataProvider.HasSubmissionAsync(applicationId)) + var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + if (submission == null || string.IsNullOrWhiteSpace(submission.Submission)) { throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]); } @@ -30,16 +43,35 @@ public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId) { - var applicationForm = await dataProvider.GetApplicationFormAsync(applicationId); - if (applicationForm?.ScoresheetId == null) + var application = await applicationRepository.GetAsync(applicationId); + var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); + if (applicationForm.ScoresheetId == null) { throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]); } - var scoresheet = await dataProvider.GetScoresheetAsync(applicationForm.ScoresheetId.Value); + var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) { throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]); } } + + public async Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormMappingRequiresFormVersion]); + } + } + + public async Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]); + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs index 5b7cfa2bae..baf2fe7990 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Operations; using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; using Volo.Abp.Uow; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 0f5a437289..89dacc0e07 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -22,7 +22,6 @@ public class AttachmentSummaryService( ITextExtractionService textExtractionService, IAIService aiService, IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, - AIExecutionModeResolver executionModeResolver, IUnitOfWorkManager unitOfWorkManager, ILogger logger, IStringLocalizer localizer) : IAttachmentSummaryService, ITransientDependency @@ -65,124 +64,20 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen throw new UserFriendlyException(localizer[AILocalizationKeys.SelectAttachmentForSummaries]); } - var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); - if (mode == AIExecutionMode.Batch) - { - return await GenerateBatchAsync(ids, promptVersion, cancellationToken); - } - - if (mode != AIExecutionMode.Sequential) - { - logger.LogWarning( - "AI attachment summary {ExecutionMode} mode is not supported by the current repository-backed execution path. Falling back to sequential execution.", - mode); - mode = AIExecutionMode.Sequential; - } - return await AIExecutionStrategy.RunAsync( ids, - mode, + AIExecutionMode.Sequential, id => GenerateOrFallbackAsync(id, promptVersion, cancellationToken), - batch => GenerateSequentiallyAsync(batch, promptVersion, cancellationToken)); - } - - private async Task> GenerateBatchAsync( - IReadOnlyCollection attachmentIds, - string? promptVersion, - CancellationToken cancellationToken) - { - var attachments = new List<(Guid Id, AttachmentSummarySource Source, string ContentType, string ExtractedText)>(attachmentIds.Count); - var failures = new Dictionary(); - - foreach (var attachmentId in attachmentIds) - { - try + async batch => { - var attachment = await LoadAttachmentAsync(attachmentId); - var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; - await using var attachmentStream = await OpenAttachmentStreamAsync(attachment, fileName, cancellationToken); - var extractedText = await textExtractionService.ExtractTextAsync(fileName, attachmentStream.Content, attachmentStream.ContentType, cancellationToken); - if (ShouldStopOnEmptyExtraction(fileName, extractedText)) + var summaries = new List(batch.Count); + foreach (var attachmentId in batch) { - LogEmptyExtraction(attachmentId, fileName, attachmentStream); - failures[attachmentId] = TextExtractionFailedSummary; - continue; + summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion, cancellationToken)); } - attachments.Add((attachmentId, attachment, attachmentStream.ContentType, extractedText)); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - logger.LogError(ex, "Error preparing AI summary batch item {AttachmentId}", attachmentId); - failures[attachmentId] = SummaryGenerationFailedMessage; - } - } - - if (attachments.Count == 0) - { - return attachmentIds.Select(id => failures.TryGetValue(id, out var failure) ? failure : SummaryGenerationFailedMessage).ToList(); - } - - var batchRequest = new AttachmentSummaryBatchRequest - { - PromptVersion = promptVersion, - Attachments = attachments.Select(item => new AttachmentSummaryBatchItemRequest - { - AttachmentId = item.Id.ToString(), - FileName = string.IsNullOrWhiteSpace(item.Source.FileName) ? "unknown" : item.Source.FileName!, - ContentType = item.ContentType, - ExtractedText = item.ExtractedText - }).ToList() - }; - - var batchResponse = await aiService.GenerateAttachmentSummaryBatchAsync(batchRequest, cancellationToken); - var responseMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var item in batchResponse.Attachments) - { - if (!string.IsNullOrWhiteSpace(item.AttachmentId)) - { - responseMap[item.AttachmentId] = item.Summary; - } - } - - var results = new List(attachmentIds.Count); - foreach (var attachmentId in attachmentIds) - { - if (failures.TryGetValue(attachmentId, out var failure)) - { - results.Add(failure); - continue; - } - - if (responseMap.TryGetValue(attachmentId.ToString(), out var summary)) - { - await SaveSummaryAsync(attachmentId, summary); - results.Add(summary); - continue; - } - - results.Add(SummaryGenerationFailedMessage); - } - - return results; - } - - private async Task> GenerateSequentiallyAsync( - IReadOnlyCollection attachmentIds, - string? promptVersion, - CancellationToken cancellationToken) - { - var summaries = new List(attachmentIds.Count); - foreach (var attachmentId in attachmentIds) - { - summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion, cancellationToken)); - } - - return summaries; + return summaries; + }); } private async Task GenerateOrFallbackAsync( diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormMappingService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormMappingService.cs new file mode 100644 index 0000000000..b48f40b1fe --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormMappingService.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Requests; +using Unity.AI.Responses; + +namespace Unity.AI.Operations; + +public interface IFormMappingService +{ + Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormWorksheetService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormWorksheetService.cs new file mode 100644 index 0000000000..75f8ec7bdf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormWorksheetService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Requests; +using Unity.AI.Responses; + +namespace Unity.AI.Operations; + +public interface IFormWorksheetService +{ + Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs index f908e1a388..ae79716c4b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs @@ -5,4 +5,6 @@ public static class AIPromptTypes public const string AttachmentSummary = "AttachmentSummary"; public const string ApplicationAnalysis = "ApplicationAnalysis"; public const string ApplicationScoring = "ApplicationScoring"; + public const string FormMapping = "FormMapping"; + public const string FormWorksheet = "FormWorksheet"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/form-worksheet.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/form-worksheet.user.txt new file mode 100644 index 0000000000..1cccb19f32 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/form-worksheet.user.txt @@ -0,0 +1,37 @@ +WORKSHEET CONTEXT: +{{DATA}} + +OUTPUT +{ + "Name": "", + "Title": "", + "Version": , + "Published": true, + "Sections": [ + { + "Name": "", + "Order": 1, + "Fields": [ + { + "Name": "", + "Key": "", + "Label": "", + "Type": , + "Definition": "" + } + ] + } + ], + "ReportColumns": "", + "ReportKeys": "", + "ReportViewName": "" +} + +Rules: +- Return one worksheet definition JSON object only. +- The context includes CHEFS fields, Unity core fields, and existing worksheet-derived custom fields. +- 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. +- Return valid plain JSON only. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt new file mode 100644 index 0000000000..3c791b606b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt @@ -0,0 +1,4 @@ +You are a careful mapping assistant for human reviewers. +Compare CHEFS fields, Unity core fields, and worksheet fields to suggest likely mappings. +Do not invent fields, persist changes, or assume a worksheet should exist if one is not clearly justified. +Return only valid JSON in the exact format requested. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt new file mode 100644 index 0000000000..3237f3c5ca --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt @@ -0,0 +1,26 @@ +FORM MAPPING CONTEXT: +{{DATA}} + +OUTPUT +{ + "": "", + "": "" +} + +Rules: +- Return one flat JSON object only. +- The context is grouped as `chefsData` and `unityData`. +- `chefsData.fields` contains the CHEFS source fields. +- `unityData.coreFields` contains Unity target fields. +- `unityData.customFields` contains worksheet-derived Unity target fields. +- Each property name must be a Unity core or worksheet-derived target field name from the provided context. +- Each property value must be the CHEFS source field name that best matches that Unity target field. +- Only include mappings that are clearly semantically equivalent or strongly related by label, name, type, and purpose. +- Do not force one-to-one coverage. Omit Unity fields when no CHEFS field is a sensible match. +- Omit CHEFS fields that do not clearly map to a Unity target field. +- Do not map platform/system identifiers such as SubmissionId, SubmissionDate, or ConfirmationId; they are managed by Unity and should be omitted if present. +- If no fields clearly match, return `{}`. +- The mapping is dynamic; do not hardcode or assume a fixed list of fields. +- Prefer existing Unity core intake fields when they already fit the source field. +- Only use worksheet custom field targets when the form genuinely needs them. +- Return valid plain JSON only. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs index e915cad4e8..67e248c811 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs @@ -40,9 +40,12 @@ public static string BuildAttachmentSummaryUserPrompt( }); } - public static string BuildAttachmentSummaryBatchUserPrompt( + public static string BuildApplicationScoringUserPrompt( string userPromptTemplate, + string data, string attachments, + string section, + string response, string? metadataJson = null) { return RenderPromptTemplate( @@ -50,16 +53,16 @@ public static string BuildAttachmentSummaryBatchUserPrompt( metadataJson, new Dictionary { - ["ATTACHMENTS"] = attachments + ["DATA"] = data, + ["ATTACHMENTS"] = attachments, + ["SECTION"] = section, + ["RESPONSE"] = response }); } - public static string BuildApplicationScoringUserPrompt( + public static string BuildFormMappingUserPrompt( string userPromptTemplate, string data, - string attachments, - string section, - string response, string? metadataJson = null) { return RenderPromptTemplate( @@ -67,10 +70,7 @@ public static string BuildApplicationScoringUserPrompt( metadataJson, new Dictionary { - ["DATA"] = data, - ["ATTACHMENTS"] = attachments, - ["SECTION"] = section, - ["RESPONSE"] = response + ["DATA"] = data }); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs index 115cc229fe..7f78c2879f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs @@ -1,6 +1,4 @@ using System; -using System.Text.Json; -using Unity.AI.Prompts; namespace Unity.AI.Runtime; @@ -10,22 +8,4 @@ public sealed record AIPromptTemplateSnapshot( string UserPrompt, string? MetadataJson) { - public UnityPromptAssetManifest? Manifest { get; } = ParseManifest(MetadataJson); - - private static UnityPromptAssetManifest? ParseManifest(string? metadataJson) - => string.IsNullOrWhiteSpace(metadataJson) - ? null - : TryDeserialize(metadataJson); - - private static UnityPromptAssetManifest? TryDeserialize(string metadataJson) - { - try - { - return JsonSerializer.Deserialize(metadataJson); - } - catch - { - return null; - } - } } 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 e481ee5823..1f8727454d 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 @@ -14,39 +14,6 @@ public static AIResponseValidationResult ValidateAttachmentSummaryText(string re : AIResponseValidationResult.Invalid("Attachment summary response was empty."); } - public static AIResponseValidationResult ValidateAttachmentSummaryBatchJson(string response) - { - if (!TryParseRootObject(response, out var root)) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response was not valid JSON."); - } - - if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response is missing required field 'attachments' (expected array)."); - } - - foreach (var attachment in attachments.EnumerateArray()) - { - if (attachment.ValueKind != JsonValueKind.Object) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response includes an invalid attachment item."); - } - - if (!attachment.TryGetProperty("attachmentId", out var attachmentId) || attachmentId.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(attachmentId.GetString())) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response is missing a valid attachmentId."); - } - - if (!attachment.TryGetProperty(AIJsonKeys.Summary, out var summary) || summary.ValueKind != JsonValueKind.String) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response is missing a valid summary."); - } - } - - return AIResponseValidationResult.Success(); - } - public static AIResponseValidationResult ValidateApplicationAnalysisJson(string response) { if (!TryParseRootObject(response, out var root)) @@ -54,48 +21,48 @@ public static AIResponseValidationResult ValidateApplicationAnalysisJson(string return AIResponseValidationResult.Invalid("Application analysis response was not valid JSON."); } - if (!root.TryGetProperty(AIJsonKeys.Decision, out var decision) || decision.ValueKind != JsonValueKind.String) + if (!root.TryGetProperty("decision", out var decision) || decision.ValueKind != JsonValueKind.String) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Decision}' (expected string)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'decision' (expected string)."); } var normalizedDecision = (decision.GetString() ?? string.Empty).Trim().ToUpperInvariant(); if (normalizedDecision != "PROCEED" && normalizedDecision != "HOLD") { return AIResponseValidationResult.Invalid( - $"Application analysis response has invalid '{AIJsonKeys.Decision}' value. Expected 'PROCEED' or 'HOLD'."); + "Application analysis response has invalid 'decision' value. Expected 'PROCEED' or 'HOLD'."); } - if (!root.TryGetProperty(AIJsonKeys.Errors, out var errors) || errors.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("errors", out var errors) || errors.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Errors}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'errors' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) || warnings.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("warnings", out var warnings) || warnings.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Warnings}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'warnings' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) || summaries.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("summaries", out var summaries) || summaries.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Summaries}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'summaries' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) || recommendations.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("recommendations", out var recommendations) || recommendations.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Recommendations}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'recommendations' (expected array)."); } if (summaries.GetArrayLength() == 0) { return AIResponseValidationResult.Invalid( - $"Application analysis response must include at least one item in '{AIJsonKeys.Summaries}'."); + "Application analysis response must include at least one item in 'summaries'."); } if (recommendations.GetArrayLength() == 0) { return AIResponseValidationResult.Invalid( - $"Application analysis response must include at least one item in '{AIJsonKeys.Recommendations}'."); + "Application analysis response must include at least one item in 'recommendations'."); } return AIResponseValidationResult.Success(); @@ -122,7 +89,7 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r $"Application scoring response is missing required answer object for question id '{questionId}'."); } - if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerValue) + if (!answerObject.TryGetProperty("answer", out var answerValue) || answerValue.ValueKind == JsonValueKind.Null || answerValue.ValueKind == JsonValueKind.Object || answerValue.ValueKind == JsonValueKind.Array) @@ -131,7 +98,7 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r $"Application scoring response is missing a valid answer for question id '{questionId}'."); } - if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) + if (!answerObject.TryGetProperty("confidence", out var confidenceValue) || confidenceValue.ValueKind != JsonValueKind.Number || !confidenceValue.TryGetDecimal(out var confidence) || confidence < 0m @@ -145,6 +112,40 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r return AIResponseValidationResult.Success(); } + public static AIResponseValidationResult ValidateFormMappingJson(string response) + { + if (!TryParseRootObject(response, out _)) + { + return AIResponseValidationResult.Invalid("Mapping suggestion response was not valid JSON."); + } + + return AIResponseValidationResult.Success(); + } + + public static AIResponseValidationResult ValidateFormWorksheetJson(string response) + { + if (!TryParseRootObject(response, out var root)) + { + 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())) + { + 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.Success(); + } + private static HashSet ExtractQuestionIds(string sectionJson) { var ids = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs index 7490142fbe..cdb7573430 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Memory; using System; using System.Linq; using System.Text.Json; @@ -17,9 +18,11 @@ public class OpenAIConfigurationResolver( IRepository modelRepository, IRepository operationRepository, IRepository promptRepository, + IMemoryCache memoryCache, IConfiguration configuration, IDataFilter multiTenantDataFilter) : ITransientDependency { + private static readonly TimeSpan SettingsCacheDuration = TimeSpan.FromMinutes(5); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true @@ -28,6 +31,7 @@ public class OpenAIConfigurationResolver( private readonly IRepository _modelRepository = modelRepository; private readonly IRepository _operationRepository = operationRepository; private readonly IRepository _promptRepository = promptRepository; + private readonly IMemoryCache _memoryCache = memoryCache; private readonly IConfiguration _configuration = configuration; private readonly IDataFilter _multiTenantDataFilter = multiTenantDataFilter; @@ -43,6 +47,12 @@ public async Task ResolveOperationSettingsAsync( string operationName, CancellationToken cancellationToken = default) { + var cacheKey = BuildOperationSettingsCacheKey(operationName); + if (_memoryCache.TryGetValue(cacheKey, out OpenAIOperationSettings? cachedSettings) && cachedSettings is not null) + { + return cachedSettings; + } + var operation = await ResolveOperationAsync(operationName, cancellationToken); if (operation == null) { @@ -74,7 +84,7 @@ public async Task ResolveOperationSettingsAsync( } var apiKey = Required($"Azure:{providerName}:ApiKey"); - return new OpenAIOperationSettings( + var settings = new OpenAIOperationSettings( providerName, model.Name, apiKey, @@ -84,6 +94,9 @@ public async Task ResolveOperationSettingsAsync( modelSettings.Temperature, operation.CompletionTokens, $"v{prompt.VersionNumber}"); + + _memoryCache.Set(cacheKey, settings, SettingsCacheDuration); + return settings; } public async Task ResolveConfiguredTemperatureAsync(string? modelName = null, CancellationToken cancellationToken = default) @@ -202,16 +215,6 @@ public async Task ResolveProfileNameAsync(string? modelName = null, Canc return (model, settings); } - private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) - { - var operations = await _operationRepository.GetListAsync( - operation => operation.IsActive, - cancellationToken: cancellationToken); - - return operations.FirstOrDefault(operation => - string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); - } - private static AIModelSettings ResolveModelSettings(AIModel model) { var settings = JsonSerializer.Deserialize(model.SettingsJson, JsonOptions); @@ -279,6 +282,11 @@ private async Task ResolvePromptVersionAsyncCore(string operationName, C return $"v{prompt.VersionNumber}"; } + private static string BuildOperationSettingsCacheKey(string operationName) + { + return $"ai:operation-settings:{operationName.Trim().ToLowerInvariant()}"; + } + private async Task LoadPromptAsync(Guid promptId, CancellationToken cancellationToken) { using (_multiTenantDataFilter.Disable()) @@ -286,4 +294,49 @@ private async Task LoadPromptAsync(Guid promptId, CancellationToken ca return await _promptRepository.GetAsync(promptId, cancellationToken: cancellationToken); } } + + private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) + { + var cacheKey = BuildOperationSnapshotCacheKey(operationName); + if (_memoryCache.TryGetValue(cacheKey, out ResolvedOperationSnapshot? cachedOperation) && cachedOperation is not null) + { + return cachedOperation; + } + + var operations = await _operationRepository.GetListAsync( + operation => operation.IsActive, + cancellationToken: cancellationToken); + + var operation = operations.FirstOrDefault(operation => + string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); + + if (operation == null) + { + return null; + } + + var snapshot = new ResolvedOperationSnapshot( + operation.Id, + operation.Name, + operation.AIModelId, + operation.AIPromptId, + operation.CompletionTokens, + operation.IsActive); + + _memoryCache.Set(cacheKey, snapshot, SettingsCacheDuration); + return snapshot; + } + + private static string BuildOperationSnapshotCacheKey(string operationName) + { + return $"ai:operation-snapshot:{operationName.Trim().ToLowerInvariant()}"; + } + + private sealed record ResolvedOperationSnapshot( + Guid Id, + string Name, + Guid AIModelId, + Guid AIPromptId, + int CompletionTokens, + bool IsActive); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs index 05c1ebb027..1cb90c3455 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs @@ -19,27 +19,27 @@ public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(strin return response; } - if (TryGetStringProperty(root, AIJsonKeys.Decision, out var decision)) + if (TryGetStringProperty(root, "decision", out var decision)) { response.Decision = decision.Trim().ToUpperInvariant(); } - if (TryGetArrayProperty(root, AIJsonKeys.Errors, out var errorsArray)) + if (TryGetArrayProperty(root, "errors", out var errorsArray)) { response.Errors = ParseFindings(errorsArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Warnings, out var warningsArray)) + if (TryGetArrayProperty(root, "warnings", out var warningsArray)) { response.Warnings = ParseFindings(warningsArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Summaries, out var summariesArray)) + if (TryGetArrayProperty(root, "summaries", out var summariesArray)) { response.Summaries = ParseFindings(summariesArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Recommendations, out var recommendationsArray)) + if (TryGetArrayProperty(root, "recommendations", out var recommendationsArray)) { response.Recommendations = ParseFindings(recommendationsArray).ToList(); } @@ -47,66 +47,6 @@ public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(strin return response; } - private static string AddIdsToAnalysisItems(string analysisJson) - { - try - { - using var jsonDoc = JsonDocument.Parse(analysisJson); - using var memoryStream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - - foreach (var property in jsonDoc.RootElement.EnumerateObject()) - { - var outputPropertyName = property.Name; - - if (outputPropertyName == AIJsonKeys.Errors || - outputPropertyName == AIJsonKeys.Warnings || - outputPropertyName == AIJsonKeys.Summaries || - outputPropertyName == AIJsonKeys.Recommendations) - { - writer.WritePropertyName(outputPropertyName); - writer.WriteStartArray(); - - foreach (var item in property.Value.EnumerateArray()) - { - writer.WriteStartObject(); - - foreach (var itemProperty in item.EnumerateObject()) - { - itemProperty.WriteTo(writer); - } - - if (!item.TryGetProperty(AIJsonKeys.Id, out var idProp) || - idProp.ValueKind != JsonValueKind.String || - string.IsNullOrWhiteSpace(idProp.GetString())) - { - writer.WriteString(AIJsonKeys.Id, Guid.NewGuid().ToString()); - } - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - continue; - } - - property.WriteTo(writer); - } - - writer.WriteEndObject(); - writer.Flush(); - } - - return Encoding.UTF8.GetString(memoryStream.ToArray()); - } - catch - { - return analysisJson; - } - } - public static ApplicationScoringResponse ParseApplicationScoringResponse(string raw, IReadOnlyDictionary? questionIdAliasMap = null) { var response = new ApplicationScoringResponse(); @@ -151,47 +91,77 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string return response; } - public static AttachmentSummaryBatchResponse ParseAttachmentSummaryBatchResponse(string raw) + public static FormMappingResponse ParseFormMappingResponse(string raw) { - var response = new AttachmentSummaryBatchResponse(); if (!TryParseJsonObjectFromResponse(raw, out var root)) { - return response; + return new FormMappingResponse(); } - if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) + return new FormMappingResponse { - return response; - } + Mapping = root.GetRawText() + }; + } - foreach (var attachment in attachments.EnumerateArray()) + private static string AddIdsToAnalysisItems(string analysisJson) + { + try { - if (attachment.ValueKind != JsonValueKind.Object) + using var jsonDoc = JsonDocument.Parse(analysisJson); + using var memoryStream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) { - continue; - } + writer.WriteStartObject(); - var attachmentId = attachment.TryGetProperty("attachmentId", out var idProp) && idProp.ValueKind == JsonValueKind.String - ? idProp.GetString() ?? string.Empty - : string.Empty; + foreach (var property in jsonDoc.RootElement.EnumerateObject()) + { + var outputPropertyName = property.Name; - if (string.IsNullOrWhiteSpace(attachmentId)) - { - continue; - } + if (outputPropertyName == "errors" || + outputPropertyName == "warnings" || + outputPropertyName == "summaries" || + outputPropertyName == "recommendations") + { + writer.WritePropertyName(outputPropertyName); + writer.WriteStartArray(); - var summary = attachment.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String - ? summaryProp.GetString() ?? string.Empty - : string.Empty; + foreach (var item in property.Value.EnumerateArray()) + { + writer.WriteStartObject(); - response.Attachments.Add(new AttachmentSummaryBatchItemResponse - { - AttachmentId = attachmentId, - Summary = summary - }); - } + foreach (var itemProperty in item.EnumerateObject()) + { + itemProperty.WriteTo(writer); + } - return response; + if (!item.TryGetProperty("id", out var idProp) || + idProp.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(idProp.GetString())) + { + writer.WriteString("id", Guid.NewGuid().ToString()); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + continue; + } + + property.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + } + + return Encoding.UTF8.GetString(memoryStream.ToArray()); + } + catch + { + return analysisJson; + } } private static IEnumerable ParseFindings(JsonElement findingsArray) @@ -204,23 +174,23 @@ private static IEnumerable ParseFindings(JsonElement } var id = Guid.NewGuid().ToString(); - if (item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) { id = idProp.GetString() ?? id; } - var dismissed = item.TryGetProperty(AIJsonKeys.Dismissed, out var dismissedProp) && + var dismissed = item.TryGetProperty("dismissed", out var dismissedProp) && (dismissedProp.ValueKind == JsonValueKind.True || dismissedProp.ValueKind == JsonValueKind.False) && dismissedProp.GetBoolean(); string? title = null; - if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("title", out var titleProp) && titleProp.ValueKind == JsonValueKind.String) { title = titleProp.GetString(); } string? detail = null; - if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("detail", out var detailProp) && detailProp.ValueKind == JsonValueKind.String) { detail = detailProp.GetString(); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 3cafa504d9..6400d33c19 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Unity.AI.Models; +using Unity.AI.Operations; using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Responses; @@ -14,8 +15,8 @@ namespace Unity.AI.Runtime { - [ExposeServices(typeof(IAIService))] - public class OpenAIRuntimeService : IAIService, ITransientDependency + [ExposeServices(typeof(IAIService), typeof(IFormMappingService), typeof(IFormWorksheetService))] + public class OpenAIRuntimeService : IAIService, IFormMappingService, IFormWorksheetService, ITransientDependency { private readonly ILogger _logger; private readonly OpenAITransportService _openAITransportService; @@ -25,6 +26,8 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; + private const string FormMappingPromptType = AIPromptTypes.FormMapping; + private const string FormWorksheetPromptType = AIPromptTypes.FormWorksheet; private const int MaxAiAttempts = 3; public OpenAIRuntimeService( @@ -155,7 +158,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta } }; var attachments = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); - var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( + var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryUserPrompt( promptTemplate.UserPrompt, attachments, promptTemplate.MetadataJson); @@ -200,56 +203,65 @@ public async Task GenerateAttachmentSummaryAsync(Atta } } - public async Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default) + public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { - if (request.Attachments is null || request.Attachments.Count == 0) - { - return new AttachmentSummaryBatchResponse(); - } - - var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(AttachmentSummaryPromptType, cancellationToken); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationScoringPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( - AttachmentSummaryPromptType, + ApplicationScoringPromptType, request.PromptVersion ?? settings.PromptVersion, cancellationToken); var promptVersion = promptTemplate.PromptVersion; + var dataJson = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented); + var sectionJson = JsonSerializer.Serialize(request.SectionSchema, AIJsonDefaults.Indented); + + var attachmentSummaries = request.Attachments + .Select(a => $"{a.Name}: {a.Summary}") + .ToList(); + var attachments = attachmentSummaries.Count > 0 + ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) + : "[]"; - var attachmentsPayload = request.Attachments.Select(attachment => new + var section = OpenAIPromptRenderer.BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); + var response = OpenAIPromptRenderer.BuildApplicationScoringResponseTemplate(section); + if (response == "{}") { - attachmentId = attachment.AttachmentId, - name = string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment" : attachment.FileName.Trim(), - contentType = attachment.ContentType ?? "application/octet-stream", - text = string.IsNullOrWhiteSpace(attachment.ExtractedText) ? null : attachment.ExtractedText - }); + _logger.LogWarning( + "Skipping AI application scoring for section {SectionName} because response template could not be built from section schema.", + request.SectionName); + return new ApplicationScoringResponse(); + } - var attachments = JsonSerializer.Serialize(attachmentsPayload, AIJsonDefaults.Indented); - var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( + var applicationScoringContent = AIPromptTemplateRenderer.BuildApplicationScoringUserPrompt( promptTemplate.UserPrompt, + dataJson, attachments, + section, + response, promptTemplate.MetadataJson); + var systemPrompt = promptTemplate.SystemPrompt; - await _promptFileLogger.LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, promptTemplate.SystemPrompt, contentToAnalyze, cancellationToken); + await _promptFileLogger.LogPromptInputAsync(ApplicationScoringPromptType, promptVersion, systemPrompt, applicationScoringContent, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( - contentToAnalyze, - promptTemplate.SystemPrompt, + applicationScoringContent, + systemPrompt, settings, settings.CompletionTokens, cancellationToken: cancellationToken), - AIProviderPayloadValidator.ValidateAttachmentSummaryBatchJson, - "attachment summary batch", + content => AIProviderPayloadValidator.ValidateApplicationScoringJson(content, section), + $"application scoring section {request.SectionName}", cancellationToken); - await _promptFileLogger.LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput, cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { - return new AttachmentSummaryBatchResponse(); + return new ApplicationScoringResponse(); } - return OpenAIResponseParser.ParseAttachmentSummaryBatchResponse(result.Content); + return OpenAIResponseParser.ParseApplicationScoringResponse(result.Content, questionIdAliasMap); } catch (OperationCanceledException) { @@ -257,70 +269,100 @@ public async Task GenerateAttachmentSummaryBatch } catch (Exception ex) { - _logger.LogError(ex, "Attachment summary batch generation failed."); - return new AttachmentSummaryBatchResponse(); + _logger.LogError(ex, "Application scoring generation failed for section {SectionName}.", request.SectionName); + return new ApplicationScoringResponse(); } } - public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default) + public async Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { - var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationScoringPromptType, cancellationToken); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(FormWorksheetPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( - ApplicationScoringPromptType, + FormWorksheetPromptType, request.PromptVersion ?? settings.PromptVersion, cancellationToken); var promptVersion = promptTemplate.PromptVersion; - var dataJson = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented); - var sectionJson = JsonSerializer.Serialize(request.SectionSchema, AIJsonDefaults.Indented); + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( + promptTemplate.UserPrompt, + dataJson, + promptTemplate.MetadataJson); - var attachmentSummaries = request.Attachments - .Select(a => $"{a.Name}: {a.Summary}") - .ToList(); - var attachments = attachmentSummaries.Count > 0 - ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) - : "[]"; + await _promptFileLogger.LogPromptInputAsync(FormWorksheetPromptType, promptVersion, systemPrompt, content, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + content, + systemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateFormWorksheetJson, + "form worksheet", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(FormWorksheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); - var section = OpenAIPromptRenderer.BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); - var response = OpenAIPromptRenderer.BuildApplicationScoringResponseTemplate(section); - if (response == "{}") + return new FormWorksheetResponse { - _logger.LogWarning( - "Skipping AI application scoring for section {SectionName} because response template could not be built from section schema.", - request.SectionName); - return new ApplicationScoringResponse(); - } + Worksheet = result.Outcome == AIOperationOutcome.Success + ? AIResponseJson.CleanJsonResponse(result.Content) + : "{}" + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Form worksheet generation failed."); + return new FormWorksheetResponse(); + } + } - var applicationScoringContent = AIPromptTemplateRenderer.BuildApplicationScoringUserPrompt( + private async Task GenerateFormMappingCoreAsync(FormMappingRequest request, string promptType, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(promptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + promptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( promptTemplate.UserPrompt, dataJson, - attachments, - section, - response, promptTemplate.MetadataJson); - var systemPrompt = promptTemplate.SystemPrompt; - await _promptFileLogger.LogPromptInputAsync(ApplicationScoringPromptType, promptVersion, systemPrompt, applicationScoringContent, cancellationToken); + await _promptFileLogger.LogPromptInputAsync(promptType, promptVersion, systemPrompt, content, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( - applicationScoringContent, + content, systemPrompt, settings, settings.CompletionTokens, cancellationToken: cancellationToken), - content => AIProviderPayloadValidator.ValidateApplicationScoringJson(content, section), - $"application scoring section {request.SectionName}", + AIProviderPayloadValidator.ValidateFormMappingJson, + "mapping suggestion", cancellationToken); - await _promptFileLogger.LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput, cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(promptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { - return new ApplicationScoringResponse(); + return new FormMappingResponse(); } - return OpenAIResponseParser.ParseApplicationScoringResponse(result.Content, questionIdAliasMap); + return new FormMappingResponse + { + Mapping = AIResponseJson.CleanJsonResponse(result.Content) + }; } catch (OperationCanceledException) { @@ -328,11 +370,14 @@ public async Task GenerateApplicationScoringAsync(Ap } catch (Exception ex) { - _logger.LogError(ex, "Application scoring generation failed for section {SectionName}.", request.SectionName); - return new ApplicationScoringResponse(); + _logger.LogError(ex, "Mapping suggestion generation failed."); + return new FormMappingResponse(); } } + public Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default) => + GenerateFormMappingCoreAsync(request, FormMappingPromptType, cancellationToken); + private async Task GenerateWithRetryAsync( Func> operation, Func validator, @@ -461,7 +506,7 @@ private static string ExtractSummaryFromJson(string output) return output?.Trim() ?? string.Empty; } - if (jsonObject.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && + if (jsonObject.TryGetProperty("summary", out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String) { return summaryProp.GetString() ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs index 769a538af1..89d503f1b1 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs @@ -51,17 +51,18 @@ public async Task GenerateSummaryAsync( var completion = result.Value; var rawResponse = result.GetRawResponse(); - var responseContent = rawResponse.Content.ToString(); + var statusCode = rawResponse?.Status; + var responseContent = rawResponse?.Content?.ToString() ?? string.Empty; var modelOutput = ExtractModelOutput(completion, responseContent); var providerResponse = BuildProviderResponseFromMetadata( modelOutput ?? string.Empty, responseContent, TryExtractProviderMetadata(responseContent), - rawResponse.Status); + statusCode); if (string.IsNullOrWhiteSpace(modelOutput)) { - LogEmptyModelOutput(completion, providerResponse, rawResponse.Status); + LogEmptyModelOutput(completion, providerResponse, statusCode ?? 0); } return string.IsNullOrWhiteSpace(modelOutput) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs index 6030c31218..1c1ed15bd7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs @@ -31,6 +31,8 @@ public override void PreConfigureServices(ServiceConfigurationContext context) public override void ConfigureServices(ServiceConfigurationContext context) { + context.Services.AddMemoryCache(); + Configure(options => { options.IsEnabled = true; @@ -55,4 +57,4 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.AddAssemblyOf(); } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs index bf8357a391..4bed1a64c4 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs @@ -3,8 +3,8 @@ using System.Linq; using Microsoft.Extensions.Logging; using System.Threading.Tasks; -using Unity.AI.Domain; using Unity.AI.Operations; +using Unity.AI.Domain; using Unity.AI.Prompts; using Unity.GrantManager.GrantApplications; using Volo.Abp.Data; @@ -27,7 +27,9 @@ public class AIOperationDataSeeder( [ new(AIPromptTypes.ApplicationAnalysis, AIPromptTypes.ApplicationAnalysis, 1, 4000), new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), - new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000) + new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000), + new(AIPromptTypes.FormMapping, AIPromptTypes.FormMapping, 2, 2000), + new(AIPromptTypes.FormWorksheet, AIPromptTypes.FormWorksheet, 2, 4000) ]; public async Task SeedAsync(DataSeedContext context) 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 64041c1d0b..fae1247a46 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 @@ -28,6 +28,8 @@ public async Task SeedAsync(DataSeedContext context) await SeedAnalysisPromptAsync(); await SeedAttachmentPromptAsync(); await SeedScoresheetPromptAsync(); + await SeedFormMappingPromptAsync(); + await SeedFormWorksheetPromptAsync(); } } @@ -110,6 +112,18 @@ await EnsurePromptAsync( commonRules: CommonRules)); } + // ─── MAPPING SUGGESTION ───────────────────────────────────────────────── + + private async Task SeedFormMappingPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormMapping, 2, FormMappingSystemV2, FormMappingUserV2, FormMappingMetadataV2); + } + + private async Task SeedFormWorksheetPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormWorksheet, 2, FormWorksheetSystemV2, FormWorksheetUserV2, FormWorksheetMetadataV2); + } + // ─── HELPERS ────────────────────────────────────────────────────────────── private static string BuildSections( @@ -780,6 +794,110 @@ 4. Choose the most conservative valid answer supported by that evidence. - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. """; + // ── v0/mapping-suggestion.system.txt ──────────────────────────────────── + private const string FormMappingSystemV2 = """ + You are a careful mapping assistant for human reviewers. + Return structured JSON for recommended Unity-to-CHEFS field mapping. + Do not invent fields, persist changes, or add wrapper sections. + Return only valid JSON in the exact mapping shape requested. + """; + + // ── v2/onboarding-mapping.user.txt ───────────────────────────────────── + private const string FormMappingUserV2 = """ + FORM MAPPING CONTEXT: + {{DATA}} + + OUTPUT + { + "": "", + "": "" + } + + Important: + - Use only FORM MAPPING CONTEXT as evidence. + - The context is grouped as chefsData and unityData. + - chefsData.fields contains the CHEFS source fields. + - unityData.coreFields contains Unity target fields. + - unityData.customFields contains worksheet-derived Unity target fields. + - existingMapping contains the current Unity-to-CHEFS assignments, when any exist. + - Return a complete mapping, including existing mappings and any new suggestions. + - Preserve every existing non-empty mapping exactly as provided; do not replace or remove it. + - Only fill blank existing mappings or add new mappings when supported by the available fields. + - Only include mappings that are clearly semantically equivalent or strongly related by label, name, type, and purpose. + - Do not force one-to-one coverage. Omit Unity fields when no CHEFS field is a sensible match. + - Omit CHEFS fields that do not clearly map to a Unity target field. + - Do not map platform/system identifiers such as SubmissionId, SubmissionDate, or ConfirmationId; they are managed by Unity and should be omitted if present. + - If no fields clearly match, return `{}`. + - The mapping is dynamic; do not hardcode or assume a fixed list of fields. + - Prefer existing Unity core intake fields when they already fit the CHEFS source field. + - Only use worksheet custom field targets when the form genuinely needs them. + - Return valid plain JSON only in the exact OUTPUT shape. + """; + + private const string FormMappingMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing CHEFS fields, Unity core fields, worksheet-derived custom fields, and the existing mapping." + } + """; + + // ── 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. + Return only valid JSON. + """; + + // ── v2/form-worksheet.user.txt ────────────────────────────────────────── + private const string FormWorksheetUserV2 = """ + WORKSHEET CONTEXT: + {{DATA}} + + OUTPUT + { + "Name": "", + "Title": "", + "Version": , + "Published": true, + "Sections": [ + { + "Name": "", + "Order": 1, + "Fields": [ + { + "Name": "", + "Key": "", + "Label": "", + "Type": , + "Definition": "" + } + ] + } + ], + "ReportColumns": "", + "ReportKeys": "", + "ReportViewName": "" + } + + Rules: + - Return one worksheet definition 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. + - 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. + - 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." + } + """; + // ── v1/common.rules.txt ────────────────────────────────────────────────── private const string CommonRules = """ - Any narrative text response must be at least 12 words. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs index 443b3602cc..2e9385f7bd 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs @@ -1,18 +1,15 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Unity.AI.Automation; using Unity.AI.Features; using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.Permissions; -using Unity.AI.RateLimit; using Unity.AI.Settings; using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantApplications.Automation; using Volo.Abp.MultiTenancy; using Volo.Abp; using Volo.Abp.Features; @@ -21,63 +18,74 @@ namespace Unity.AI.Generation; [Route("api/app/ai/generation")] public class AIGenerationAppService( - IApplicationAIGenerationQueue aiGenerationQueue, + IApplicationGenerationQueue aiGenerationQueue, IAIGenerationStatusAppService aiGenerationStatusAppService, - IAIRateLimiter aiRateLimiter, AIFeatureGuard featureGuard, ICurrentTenant currentTenant) : AIAppService, IAIGenerationAppService { - private const string ApplicationAnalysisOperationType = "application-analysis"; - private const string AttachmentSummaryOperationType = "attachment-summary"; - private const string ApplicationScoringOperationType = "application-scoring"; - [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [HttpPost("attachment-summary")] - public virtual async Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input) + public virtual async Task GenerateApplicationAttachmentSummariesAsync(AttachmentSummaryGenerationRequestDto request) { await featureGuard.EnsureEnabledAsync( AIFeatures.AttachmentSummaries, AILocalizationKeys.AttachmentSummariesDisabled); - if (input.AttachmentIds.Count == 0) + if (request.AttachmentIds.Count == 0) { - return []; + return; } - await aiGenerationQueue.QueueAttachmentSummaryAsync( - input.ApplicationId, + await aiGenerationQueue.QueueApplicationAttachmentSummaryAsync( + request.ApplicationId, currentTenant.Id, - input.PromptVersion, - input.AttachmentIds); - - return input.AttachmentIds - .Select(_ => new AttachmentSummaryResultDto { Completed = false }) - .ToList(); + request.AttachmentIds, + request.PromptVersion); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] [HttpPost("application-analysis")] - public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) + public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.ApplicationAnalysis, AILocalizationKeys.ApplicationAnalysisDisabled); await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, currentTenant.Id, promptVersion); - return new ApplicationAnalysisResultDto { Completed = false }; } [Authorize(AIPermissions.Analysis.GenerateScoring)] [HttpPost("application-scoring")] - public virtual async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) + public virtual async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.Scoring, AILocalizationKeys.ScoringDisabled); await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, currentTenant.Id, promptVersion); - return new ApplicationScoringResultDto { Completed = false }; + } + + [Authorize(AIPermissions.Analysis.GenerateFormMapping)] + [HttpPost("form-mapping")] + public virtual async Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormMapping, + AILocalizationKeys.FormMappingDisabled); + + await aiGenerationQueue.QueueFormMappingAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); + } + + [Authorize(AIPermissions.Analysis.GenerateFormWorksheet)] + [HttpPost("form-worksheet")] + public virtual async Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormWorksheet, + AILocalizationKeys.FormWorksheetDisabled); + + await aiGenerationQueue.QueueFormWorksheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); } [Authorize] @@ -87,27 +95,34 @@ public virtual async Task GetStatusAsync(Guid application await EnsureStatusAccessAsync(operationType); var request = await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, currentTenant.Id); - var state = await aiRateLimiter.GetStateAsync(); + if (request == null) + { + return new AIGenerationStatusDto(); + } return new AIGenerationStatusDto { - GenerationRequest = request == null - ? null - : new AIGenerationStatusRequestDto - { - Id = request.Id, - ApplicationId = request.ApplicationId, - OperationId = request.OperationId, - OperationType = operationType, - Status = request.Status.ToString(), - StartedAt = request.StartedAt, - CompletedAt = request.CompletedAt, - FailureReason = request.FailureReason, - IsActive = request.IsActive - }, - FailureReason = request?.FailureReason, - IsGenerating = state.IsGenerating, - RetryAfterSeconds = state.RetryAfterSeconds + GenerationRequest = new AIGenerationRequestDto + { + Id = request.Id, + ApplicationId = request.ApplicationId, + OperationId = request.OperationId, + OperationType = operationType, + Status = request.Status.ToString(), + StartedAt = request.StartedAt, + CompletedAt = request.CompletedAt, + FailureReason = request.FailureReason, + IsActive = request.IsActive + }, + Id = request.Id, + ApplicationId = request.ApplicationId, + OperationId = request.OperationId, + OperationType = operationType, + Status = request.Status.ToString(), + StartedAt = request.StartedAt, + CompletedAt = request.CompletedAt, + FailureReason = request.FailureReason, + IsActive = request.IsActive }; } @@ -115,21 +130,14 @@ private async Task EnsureStatusAccessAsync(string operationType) { var permission = operationType switch { - ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, - AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, - ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, - AIGenerationRequestKeyHelper.PipelineOperationType => null, + AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, + AIGenerationRequestKeyHelper.ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, + AIGenerationRequestKeyHelper.FormMappingOperationType => AIPermissions.Analysis.ViewFormMapping, + AIGenerationRequestKeyHelper.FormWorksheetOperationType => AIPermissions.Analysis.ViewFormWorksheet, _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}") }; - if (permission is null) - { - await CheckPolicyAsync(AIPermissions.Analysis.ViewApplicationAnalysis); - await CheckPolicyAsync(AIPermissions.Analysis.ViewAttachmentSummary); - await CheckPolicyAsync(AIPermissions.Analysis.ViewScoringResult); - return; - } - await CheckPolicyAsync(permission); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs new file mode 100644 index 0000000000..f7bcf4eb1c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.AI.Generation; + +public interface IApplicationGenerationQueue +{ + Task QueueApplicationAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, List attachmentIds, string? promptVersion = null); + + Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + + Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + + Task QueueFormMappingAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + + Task QueueFormWorksheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + + Task QueueApplicationIntakeAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AICooldownService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AICooldownService.cs new file mode 100644 index 0000000000..136d7d3ae7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AICooldownService.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Medallion.Threading; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Cooldown; + +public interface IAICooldownService +{ + Task GetRemainingSecondsAsync(Guid userId); + Task EnsureAsync(Guid? userId); + Task StampAsync(Guid? userId); +} + +public class AICooldownService( + IDistributedCache cache, + IConfiguration configuration, + IDistributedLockProvider distributedLockProvider) + : IAICooldownService, ITransientDependency +{ + public async Task GetRemainingSecondsAsync(Guid userId) + { + var raw = await cache.GetStringAsync(KeyFor(userId)); + if (string.IsNullOrEmpty(raw) || + !long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var untilTicks) || + untilTicks < DateTime.MinValue.Ticks || + untilTicks > DateTime.MaxValue.Ticks) + { + return 0; + } + + var seconds = (int)Math.Ceiling((new DateTime(untilTicks, DateTimeKind.Utc) - DateTime.UtcNow).TotalSeconds); + return seconds > 0 ? seconds : 0; + } + + public async Task EnsureAsync(Guid? userId) + { + if (userId is not Guid resolvedUserId) + { + return; + } + + var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + resolvedUserId); + using (await userLock.AcquireAsync()) + { + var remaining = await GetRemainingSecondsAsync(resolvedUserId); + if (remaining > 0) + { + throw new UserFriendlyException( + $"AI generation is rate limited. Try again in {remaining} second{(remaining == 1 ? "" : "s")}."); + } + } + } + + public async Task StampAsync(Guid? userId) + { + if (userId is Guid resolvedUserId) + { + var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + resolvedUserId); + using (await userLock.AcquireAsync()) + { + await StampAsync(resolvedUserId, CooldownSeconds); + } + } + } + + private const string CooldownKeyPrefix = "ai-generation:cooldown:"; + private const string CooldownLockPrefix = "ai-generation:cooldown-lock:"; + private const string CooldownSecondsConfigurationKey = "Azure:Generation:CooldownSeconds"; + + private int CooldownSeconds + { + get + { + var configured = configuration.GetValue(CooldownSecondsConfigurationKey); + if (configured is > 0) + { + return configured.Value; + } + + throw new AbpException($"{CooldownSecondsConfigurationKey} must be configured with a positive value."); + } + } + + private async Task StampAsync(Guid userId, int seconds) + { + var until = DateTime.UtcNow.AddSeconds(seconds); + await cache.SetStringAsync( + KeyFor(userId), + until.Ticks.ToString(CultureInfo.InvariantCulture), + new DistributedCacheEntryOptions { AbsoluteExpiration = until }); + } + + private static string KeyFor(Guid userId) => CooldownKeyPrefix + userId; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs index 2f0f06f309..fe5a68b569 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -86,11 +86,15 @@ public virtual async Task GetStateAsync() return new AIRateLimitStateDto { RetryAfterSeconds = 0, IsGenerating = false }; } - return new AIRateLimitStateDto + var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + userId); + using (await userLock.AcquireAsync()) { - RetryAfterSeconds = await GetRemainingSecondsAsync(userId), - IsGenerating = await HasActiveGenerationAsync() - }; + return new AIRateLimitStateDto + { + RetryAfterSeconds = await GetRemainingSecondsAsync(userId), + IsGenerating = await HasActiveGenerationAsync() + }; + } } private async Task HasActiveGenerationAsync() 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 83d79cee4d..2be3ad05e5 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 @@ -36,7 +36,7 @@ - + PreserveNewest PreserveNewest diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs index e658c36350..0b3720649e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs @@ -6,4 +6,6 @@ public static class AIFeatures public const string AttachmentSummaries = "Unity.AI.AttachmentSummaries"; public const string ApplicationAnalysis = "Unity.AI.ApplicationAnalysis"; public const string Scoring = "Unity.AI.Scoring"; + public const string FormMapping = "Unity.AI.FormMapping"; + public const string FormWorksheet = "Unity.AI.FormWorksheet"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index 3f72aff220..ca66506e4f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -7,9 +7,13 @@ "Permission:AI.ViewApplicationAnalysis": "View AI Application Analysis", "Permission:AI.ViewAttachmentSummary": "View AI Attachment Summary", "Permission:AI.ViewScoringResult": "View AI Scoring Result", + "Permission:AI.ViewFormMapping": "View AI Form Mapping", + "Permission:AI.ViewFormWorksheet": "View AI Form Worksheet", "Permission:AI.GenerateApplicationAnalysis": "Generate AI Application Analysis", "Permission:AI.GenerateAttachmentSummaries": "Generate AI Attachment Summaries", "Permission:AI.GenerateScoring": "Generate AI Scoring", + "Permission:AI.GenerateFormMapping": "Generate AI Form Mapping", + "Permission:AI.GenerateFormWorksheet": "Generate AI Form Worksheet", "Permission:AI.ConfigureAI": "AI Configuration", "Permission:AI.Prompts": "AI Prompt Management", "Permission:AI.Prompts.Create": "Create Prompts", @@ -23,6 +27,10 @@ "AI:AttachmentSummariesDisabled": "AI attachment summaries are not enabled.", "AI:ApplicationAnalysisDisabled": "AI application analysis is not enabled.", "AI:ScoringDisabled": "AI scoring is not enabled.", + "AI:FormMappingRequiresFormVersion": "AI form mapping requires a valid form version.", + "AI:FormMappingDisabled": "AI form mapping is not enabled.", + "AI:FormWorksheetRequiresFormVersion": "AI form worksheet requires a valid form version.", + "AI:FormWorksheetDisabled": "AI form worksheet is not enabled.", "AI:GenerateAllDisabled": "AI generation is not enabled.", "AI:NoAttachmentsAvailable": "No attachments are available to summarize.", "AI:ApplicationAnalysisRequiresSubmission": "AI application analysis requires application submission data.", diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs index 07b526d9a4..4b1b86d570 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs @@ -5,6 +5,10 @@ public static class AILocalizationKeys public const string AttachmentSummariesDisabled = "AI:AttachmentSummariesDisabled"; public const string ApplicationAnalysisDisabled = "AI:ApplicationAnalysisDisabled"; public const string ScoringDisabled = "AI:ScoringDisabled"; + public const string FormMappingRequiresFormVersion = "AI:FormMappingRequiresFormVersion"; + public const string FormMappingDisabled = "AI:FormMappingDisabled"; + public const string FormWorksheetRequiresFormVersion = "AI:FormWorksheetRequiresFormVersion"; + public const string FormWorksheetDisabled = "AI:FormWorksheetDisabled"; public const string GenerateAllDisabled = "AI:GenerateAllDisabled"; public const string NoAttachmentsAvailable = "AI:NoAttachmentsAvailable"; public const string ApplicationAnalysisRequiresSubmission = "AI:ApplicationAnalysisRequiresSubmission"; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs index 50eea177db..deba87c054 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Unity.AI.Localization; using Unity.AI.Permissions; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; using Volo.Abp.Features; @@ -27,14 +28,13 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex var specializationChecker = context.ServiceProvider.GetRequiredService(); if (!await specializationChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) { - context.Menu.AddItem(new ApplicationMenuItem( + await context.AddItemAsync(new ApplicationMenuItem( name: AIMenus.Prompts, displayName: "AI Prompts", url: "~/Prompts", icon: "fl fl-ai-prompts", - order: 900, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName - )); + order: 900 + ).OnlyWhenInRole(IdentityConsts.ITOperationsRoleName)); } if (await featureChecker.IsEnabledAsync("Unity.AIReporting")) @@ -48,5 +48,6 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex requiredPermissionName: AIPermissions.Reporting.ReportingDefault )); } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs index c455ea168c..6cd16e018a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs @@ -6,4 +6,5 @@ public static class AIMenus public const string Prompts = Prefix + ".Prompts"; public const string Reporting = Prefix + ".Reporting"; + public const string FormMapping = Prefix + ".FormMapping"; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs index b91598fed9..25e217be4a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs @@ -8,6 +8,11 @@ public sealed class CreateWorksheetDto { public string Name { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; + public uint Version { get; set; } = 1; + public bool Published { get; set; } = false; + public string ReportColumns { get; set; } = string.Empty; + public string ReportKeys { get; set; } = string.Empty; + public string ReportViewName { get; set; } = string.Empty; public List Sections { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs index 3f1a07c05a..7d5c2e8a8d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs @@ -58,6 +58,9 @@ public virtual async Task CreateAsync(CreateWorksheetDto dto) } var newWorksheet = new Worksheet(Guid.NewGuid(), worksheetName, dto.Title); + newWorksheet.SetVersion(dto.Version); + newWorksheet.SetPublished(dto.Published); + newWorksheet.SetReportingFields(dto.ReportKeys, dto.ReportColumns, dto.ReportViewName); foreach (var section in dto.Sections.OrderBy(s => s.Order)) { diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js index 5c90f25eb6..24f9a42dc4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js @@ -490,7 +490,7 @@ function savePreviewChanges(questionId, inputFieldPrefix, saveButtonPrefix, disc const saveButton = document.getElementById(saveButtonPrefix + questionId); const discardButton = document.getElementById(discardButtonPrefix + questionId); - inputField.setAttribute('data-original-value', inputField.value); + inputField.dataset.originalValue = inputField.value; saveButton.disabled = true; discardButton.disabled = true; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index 1cd1489413..2c3974a5ee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -75,7 +75,7 @@ protected virtual async Task NotifyTeamsChannel(string chesEmailError) string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); string activityTitle = "CHES Email error: " + chesEmailError; string activitySubtitle = "Environment: " + envInfo; - await notificationAppService.PostToTeamsAsync(activityTitle, activitySubtitle); + await notificationAppService.PostToNotificationsAsync(activityTitle, activitySubtitle); } public Task GetBaseUrlAsync() diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/LogNotifications/LogNotificationService.cs similarity index 68% rename from applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs rename to applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/LogNotifications/LogNotificationService.cs index cd89042b76..2df00dceea 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/LogNotifications/LogNotificationService.cs @@ -1,156 +1,156 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Unity.GrantManager.Notifications.Teams; - - -namespace Unity.Notifications.Teams -{ - public class TeamsNotificationService - { - public TeamsNotificationService() : base() { } - - public const string DIRECT_MESSAGE_KEY_PREFIX = "DIRECT_MESSAGE_"; - public const string TEAMS_ALERT = $"{DIRECT_MESSAGE_KEY_PREFIX}0"; - public const string TEAMS_NOTIFICATION = $"{DIRECT_MESSAGE_KEY_PREFIX}1"; - - public static string TeamsChannel { get; set; } = string.Empty; - - private readonly List _facts = []; - - public async Task PostFactsToTeamsAsync(string teamsChannel, string activityTitle, string activitySubtitle) - { - if (!teamsChannel.IsNullOrEmpty()) - { - string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, _facts); - await PostToTeamsChannelAsync(teamsChannel, messageCard); - } - } - - public static async Task PostToTeamsAsync(string teamsChannel, string activityTitle, string activitySubtitle, List facts) - { - if(!teamsChannel.IsNullOrEmpty()) { - string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, facts); - await PostToTeamsChannelAsync(teamsChannel, messageCard); - } - } - - public List AddFact(string Name, string Value) - { - var fact = new Fact - { - Name = Name, - Value = Value - }; - - _facts.Add(fact); - return _facts; - } - - private static class ChefsEventTypesConsts - { - public const string FORM_PUBLISHED = "eventFormPublished"; - public const string FORM_UN_PUBLISHED = "eventFormUnPublished"; - public const string FORM_DRAFT_PUBLISHED = "eventFormDraftPublished"; - } - - public static string InitializeMessageCard(string activityTitle, string activitySubtitle, List facts) - { - dynamic messageCard = MessageCard.GetMessageCard(); - JObject jsonObj = JsonConvert.DeserializeObject(messageCard)!; - string messageCardString = string.Empty; - - if(jsonObj != null) - { - jsonObj["summary"] = "Message Summary"; - - if(jsonObj["sections"] != null) - { - var sections = jsonObj["sections"]; - var firstChild = sections?.Children().First(); - - if (firstChild != null) - { - firstChild["activityTitle"] = activityTitle; - firstChild["activitySubtitle"] = activitySubtitle; - // Add Facts - foreach (var fact in facts) - { - JObject obj = JObject.Parse(JsonConvert.SerializeObject(fact)); - firstChild.Value("facts")?.Add(obj); - } - } - } - - messageCardString = jsonObj.ToString(Formatting.None); - } - - return messageCardString; - } - - public static async Task PostChefsEventToTeamsAsync(string teamsChannel, string subscriptionEvent, dynamic form, dynamic chefsFormVersion) - { - string eventDescription = subscriptionEvent switch - { - ChefsEventTypesConsts.FORM_DRAFT_PUBLISHED => "A Draft CHEFS form was published", - ChefsEventTypesConsts.FORM_PUBLISHED => "A CHEFS form was published", - ChefsEventTypesConsts.FORM_UN_PUBLISHED => "A CHEFS form was un-published", - _ => "An Unknown CHEFS event " + subscriptionEvent + " was fired " - }; - - JObject formObject = JObject.Parse(form.ToString()); - var formName = formObject.SelectToken("name"); - - // version - JToken? version = ((JObject)chefsFormVersion).SelectToken("version"); - JToken? published = ((JObject)chefsFormVersion).SelectToken("published"); - JToken? createdBy = ((JObject)chefsFormVersion).SelectToken("createdBy"); - JToken? createdAt = ((JObject)chefsFormVersion).SelectToken("createdAt"); - JToken? updatedBy = ((JObject)chefsFormVersion).SelectToken("updatedBy"); - JToken? updatedAt = ((JObject)chefsFormVersion).SelectToken("updatedAt"); - - string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - string activityTitle = eventDescription + " with an event posting to the " + envInfo + " environment"; - - string activitySubtitle = "Form Name: " + formName?.ToString(); - - // Fix for IDE0028: Simplify collection initialization - List facts = - [ - new Fact { Name = "Form Version: ", Value = version?.ToString() ?? string.Empty }, - new Fact { Name = "Published: ", Value = published?.ToString() ?? string.Empty }, - new Fact { Name = "Updated By: ", Value = updatedBy?.ToString() ?? string.Empty }, - new Fact { Name = "Updated At: ", Value = updatedAt?.ToString() + " UTC" }, - new Fact { Name = "Created By: ", Value = createdBy?.ToString() ?? string.Empty }, - new Fact { Name = "Created At: ", Value = createdAt?.ToString() + " UTC" } - ]; - - await PostToTeamsAsync(teamsChannel, activityTitle, activitySubtitle, facts); - } - - private static readonly HttpClient httpClient = new(); - - /// - /// Posts a message card to the specified Microsoft Teams channel using an HTTP POST request. - /// - /// The webhook URL of the Teams channel. - /// The message card payload in JSON format. - public static async Task PostToTeamsChannelAsync(string teamsChannel, string messageCard) - { - using var request = new HttpRequestMessage(HttpMethod.Post, teamsChannel); - request.Content = new StringContent(messageCard); - request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); - var response = await httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - // Optionally log or throw an exception here - throw new HttpRequestException($"Failed to post to Teams channel. Status code: {response.StatusCode}"); - } - } - } -} +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Unity.GrantManager.Notifications.Logs; + + +namespace Unity.Notifications.Teams +{ + public class LogNotificationService + { + public LogNotificationService() : base() { } + + public const string DIRECT_MESSAGE_KEY_PREFIX = "DIRECT_MESSAGE_"; + public const string TEAMS_ALERT = $"{DIRECT_MESSAGE_KEY_PREFIX}0"; + public const string TEAMS_NOTIFICATION = $"{DIRECT_MESSAGE_KEY_PREFIX}1"; + + + + private readonly List _facts = []; + + public async Task LogFactsToNotificationsAsync(NotificationType NotificationType, string activityTitle, string activitySubtitle) + { + + string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, _facts); + await PostToNotificationsChannelAsync(NotificationType, messageCard); + } + + public static async Task PostToNotificationsAsync(NotificationType NotificationType, string activityTitle, string activitySubtitle, List facts) + { + string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, facts); + await PostToNotificationsChannelAsync(NotificationType, messageCard); + + } + + public List AddFact(string Name, string Value) + { + var fact = new Fact + { + Name = Name, + Value = Value + }; + + _facts.Add(fact); + return _facts; + } + + private static class ChefsEventTypesConsts + { + public const string FORM_PUBLISHED = "eventFormPublished"; + public const string FORM_UN_PUBLISHED = "eventFormUnPublished"; + public const string FORM_DRAFT_PUBLISHED = "eventFormDraftPublished"; + } + + public static string InitializeMessageCard(string activityTitle, string activitySubtitle, List facts) + { + dynamic messageCard = MessageCard.GetMessageCard(); + JObject jsonObj = JsonConvert.DeserializeObject(messageCard)!; + string messageCardString = string.Empty; + + if(jsonObj != null) + { + jsonObj["summary"] = "Message Summary"; + + if(jsonObj["sections"] != null) + { + var sections = jsonObj["sections"]; + var firstChild = sections?.Children().First(); + + if (firstChild != null) + { + firstChild["activityTitle"] = activityTitle; + firstChild["activitySubtitle"] = activitySubtitle; + // Add Facts + foreach (var fact in facts) + { + JObject obj = JObject.Parse(JsonConvert.SerializeObject(fact)); + firstChild.Value("facts")?.Add(obj); + } + } + } + + messageCardString = jsonObj.ToString(Formatting.None); + } + + return messageCardString; + } + + public static async Task PostChefsEventToNotificationsAsync(NotificationType notificationType, string subscriptionEvent, dynamic form, dynamic chefsFormVersion) + { + string eventDescription = subscriptionEvent switch + { + ChefsEventTypesConsts.FORM_DRAFT_PUBLISHED => "A Draft CHEFS form was published", + ChefsEventTypesConsts.FORM_PUBLISHED => "A CHEFS form was published", + ChefsEventTypesConsts.FORM_UN_PUBLISHED => "A CHEFS form was un-published", + _ => "An Unknown CHEFS event " + subscriptionEvent + " was fired " + }; + + JObject formObject = JObject.Parse(form.ToString()); + var formName = formObject.SelectToken("name"); + + // version + JToken? version = ((JObject)chefsFormVersion).SelectToken("version"); + JToken? published = ((JObject)chefsFormVersion).SelectToken("published"); + JToken? createdBy = ((JObject)chefsFormVersion).SelectToken("createdBy"); + JToken? createdAt = ((JObject)chefsFormVersion).SelectToken("createdAt"); + JToken? updatedBy = ((JObject)chefsFormVersion).SelectToken("updatedBy"); + JToken? updatedAt = ((JObject)chefsFormVersion).SelectToken("updatedAt"); + + string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + string activityTitle = eventDescription + " with an event posting to the " + envInfo + " environment"; + + string activitySubtitle = "Form Name: " + formName?.ToString(); + + // Fix for IDE0028: Simplify collection initialization + List facts = + [ + new Fact { Name = "Form Version: ", Value = version?.ToString() ?? string.Empty }, + new Fact { Name = "Published: ", Value = published?.ToString() ?? string.Empty }, + new Fact { Name = "Updated By: ", Value = updatedBy?.ToString() ?? string.Empty }, + new Fact { Name = "Updated At: ", Value = updatedAt?.ToString() + " UTC" }, + new Fact { Name = "Created By: ", Value = createdBy?.ToString() ?? string.Empty }, + new Fact { Name = "Created At: ", Value = createdAt?.ToString() + " UTC" } + ]; + + await PostToNotificationsAsync(notificationType, activityTitle, activitySubtitle, facts); + } + + private static readonly HttpClient httpClient = new(); + + /// + /// Posts a message card to the specified Notifications channel using an HTTP POST request. + /// + /// The type of notification. + /// The message card payload in JSON format. + public static async Task PostToNotificationsChannelAsync(NotificationType notificationType, string messageCard) + { + // using var request = new HttpRequestMessage(HttpMethod.Post, ""); + // request.Content = new StringContent(messageCard); + // request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); + // var response = await httpClient.SendAsync(request); + // if (!response.IsSuccessStatusCode) + // { + // // Optionally log or throw an exception here + // throw new HttpRequestException($"Failed to post to Notifications channel. Status code: {response.StatusCode}"); + // } + + // REWRITE this - sends to teems channel but we can't anymore + // Would like this to create a push notification to the Unity Notifications service which will then send to Teams channel + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 4d457fb2d9..c9e2a94ab5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -6,12 +6,12 @@ namespace Unity.Notifications.EntityFrameworkCore; [ConnectionStringName("Default")] -public class GrantManagerDbContext : AbpDbContext +public class GrantManagerDbContext : AbpDbContext { public DbSet DynamicUrls { get; set; } // Add DbSet for each Aggregate Root here. - public GrantManagerDbContext(DbContextOptions options) + public GrantManagerDbContext(DbContextOptions options) : base(options) { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js index 63f56bf722..538c9c590a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js @@ -95,7 +95,6 @@ const emailGroupsManager = { // Setup user search dropdown functionality setupUserSearchDropdown: function(searchInputId, dropdownId, addButtonId, onUserSelected) { - const self = this; let searchTimeout; let allUsers = []; let selectedUser = null; @@ -118,8 +117,8 @@ const emailGroupsManager = { searchTimeout = setTimeout(() => { if (searchTerm.length > 0) { - const filteredUsers = self.filterUsersBySearchTerm(allUsers, searchTerm); - self.displayFilteredUsers(dropdownId, filteredUsers); + const filteredUsers = emailGroupsManager.utils.filterUsersBySearchTerm(allUsers, searchTerm); + emailGroupsManager.utils.displayFilteredUsers(dropdownId, filteredUsers); } else { $(`#${dropdownId}`).html('
  • Start typing to search users...
  • '); } @@ -217,8 +216,6 @@ const emailGroupsManager = { }, initializeDataTable: function () { - const self = this; - emailGroupsTable = $('#EmailGroupsTable').DataTable(abp.libs.datatables.normalizeConfiguration({ processing: true, serverSide: false, @@ -228,16 +225,16 @@ const emailGroupsManager = { scrollX: true, ordering: true, ajax: function (requestData, callback, settings) { - self.loadGroupsForDataTable(callback); + emailGroupsManager.loadGroupsForDataTable(callback); }, - columnDefs: self.defineColumnDefs() + columnDefs: emailGroupsManager.defineColumnDefs() })); // Bind events to the DataTable emailGroupsTable.on('click', 'td button.manage-users-btn', function (event) { event.stopPropagation(); const rowData = emailGroupsTable.row(event.target.closest('tr')).data(); - self.showManageUsersModal(rowData); + emailGroupsManager.showManageUsersModal(rowData); }); emailGroupsTable.on('click', 'td button.delete-group-btn', function (event) { @@ -246,7 +243,7 @@ const emailGroupsManager = { // Check for both lowercase and uppercase const isDynamic = rowData.type === 'dynamic' || rowData.type === 'Dynamic'; if (isDynamic) { - self.deleteGroup(rowData.id); + emailGroupsManager.deleteGroup(rowData.id); } }); }, @@ -315,13 +312,11 @@ const emailGroupsManager = { }, bindEvents: function () { - const self = this; - // Remove any existing handlers first $('#CreateNewEmailGroup').off('click'); $('#CreateNewEmailGroup').on('click', function () { - self.showCreateGroupModal(); + emailGroupsManager.showCreateGroupModal(); }); }, @@ -344,7 +339,6 @@ const emailGroupsManager = { showCreateGroupModal: function () { - const self = this; const selectedUsers = []; // Cache for selected users const modalHtml = ` @@ -422,7 +416,7 @@ const emailGroupsManager = { // Wait for modal to be fully shown before initializing DataTable $('#createGroupModal').on('shown.bs.modal', function () { createGroupUsersTable = $('#createGroupUsersTable').DataTable(abp.libs.datatables.normalizeConfiguration( - self.utils.getStandardDataTableConfig([]) + emailGroupsManager.utils.getStandardDataTableConfig([]) )); // Force columns to adjust @@ -432,7 +426,7 @@ const emailGroupsManager = { modal.show(); // Initialize user search with shared utility - self.utils.setupUserSearchDropdown( + emailGroupsManager.utils.setupUserSearchDropdown( 'createGroupUserSearch', 'createGroupUserDropdown', 'createAddUserBtn', @@ -458,8 +452,8 @@ const emailGroupsManager = { ); // Override the default displayFilteredUsers for create modal to exclude selected users - const originalDisplayFilteredUsers = self.utils.displayFilteredUsers.bind(self.utils); - self.utils.displayFilteredUsers = function(dropdownId, filteredUsers, excludeUserIds = []) { + const originalDisplayFilteredUsers = emailGroupsManager.utils.displayFilteredUsers.bind(emailGroupsManager.utils); + emailGroupsManager.utils.displayFilteredUsers = function(dropdownId, filteredUsers, excludeUserIds = []) { if (dropdownId === 'createGroupUserDropdown') { const selectedUserIds = selectedUsers.map(u => u.userId); excludeUserIds = [...excludeUserIds, ...selectedUserIds]; @@ -525,7 +519,7 @@ const emailGroupsManager = { $('#createGroupModal').on('hidden.bs.modal', function () { // Restore original displayFilteredUsers function - self.utils.displayFilteredUsers = originalDisplayFilteredUsers; + emailGroupsManager.utils.displayFilteredUsers = originalDisplayFilteredUsers; // Clean up DataTable if (createGroupUsersTable) { @@ -559,7 +553,6 @@ const emailGroupsManager = { }, showManageUsersModal: function (group) { - const self = this; const isDynamic = group.type === 'dynamic' || group.type === 'Dynamic'; // Track changes locally @@ -688,13 +681,13 @@ const emailGroupsManager = { $('#manageUsersModal').one('shown.bs.modal', () => { groupUsersTable = $('#groupUsersTable').DataTable( - abp.libs.datatables.normalizeConfiguration(self.utils.getStandardDataTableConfig([])) + abp.libs.datatables.normalizeConfiguration(emailGroupsManager.utils.getStandardDataTableConfig([])) ); - self.loadGroupUsersForTable(group.id, groupUsersTable); + emailGroupsManager.loadGroupUsersForTable(group.id, groupUsersTable); }); // Initialize user search with shared utility and custom filtering for existing group users - self.utils.setupUserSearchDropdown( + emailGroupsManager.utils.setupUserSearchDropdown( 'userSearchInput', 'userDropdownMenu', 'manageAddUserBtn', @@ -704,8 +697,8 @@ const emailGroupsManager = { ); // Override the default displayFilteredUsers for manage modal to exclude current group users - const originalDisplayFilteredUsers = self.utils.displayFilteredUsers.bind(self.utils); - self.utils.displayFilteredUsers = function(dropdownId, filteredUsers, excludeUserIds = []) { + const originalDisplayFilteredUsers = emailGroupsManager.utils.displayFilteredUsers.bind(emailGroupsManager.utils); + emailGroupsManager.utils.displayFilteredUsers = function(dropdownId, filteredUsers, excludeUserIds = []) { if (dropdownId === 'userDropdownMenu') { // Get current group users from DataTable and exclude them const currentUsers = groupUsersTable ? groupUsersTable.rows().data().toArray() : []; @@ -827,7 +820,7 @@ const emailGroupsManager = { $('#manageUsersModal').on('hidden.bs.modal', function () { // Restore original displayFilteredUsers function - self.utils.displayFilteredUsers = originalDisplayFilteredUsers; + emailGroupsManager.utils.displayFilteredUsers = originalDisplayFilteredUsers; if (groupUsersTable) { groupUsersTable.destroy(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs index ac0498a57f..36c55bc26b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs @@ -62,4 +62,4 @@ public static explicit operator PaymentRequestDto(CreatePaymentRequestDto v) } } #pragma warning restore CS8618 -} +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestListInputDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestListInputDto.cs index ede13c1d91..242d8a232b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestListInputDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestListInputDto.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Volo.Abp.Application.Dtos; @@ -6,5 +7,7 @@ namespace Unity.Payments.PaymentRequests public class PaymentRequestListInputDto : PagedAndSortedResultRequestDto { public IReadOnlyList? RequestedFields { get; set; } + public DateTime? RequestedFromDate { get; set; } + public DateTime? RequestedToDate { get; set; } } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs index f9c74fb61c..e8eb555b65 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs @@ -13,7 +13,7 @@ public interface IPaymentRequestQueryManager Task GetPaymentRequestCountAsync(); Task GetPaymentRequestByIdAsync(Guid paymentRequestId); Task> GetPaymentRequestsByIdsAsync(List paymentRequestIds, bool includeDetails = false); - Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting, IReadOnlyList? requestedFields = null); + Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting, IReadOnlyList? requestedFields = null, DateTime? requestedFromDate = null, DateTime? requestedToDate = null); Task> GetListByApplicationIdAsync(Guid applicationId); Task> GetListByApplicationIdsAsync(List applicationIds); Task> GetListByPaymentIdsAsync(List paymentIds); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs index 0cc8b10f6c..cbf5075072 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -23,6 +23,9 @@ public class PaymentRequestQueryManager( IObjectMapper objectMapper, IApplicationRepository applicationRepository) : DomainService, IPaymentRequestQueryManager { + private static readonly TimeZoneInfo VancouverTimeZone = + TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); + private static readonly HashSet SiteFields = new(StringComparer.OrdinalIgnoreCase) { "siteNumber", @@ -60,6 +63,42 @@ public class PaymentRequestQueryManager( "category" }; + /// + /// Converts Vancouver local date range to UTC range (inclusive) + /// + private static (DateTime? FromUtc, DateTime? ToUtc) ConvertToUtcRange( + DateTime? fromLocal, + DateTime? toLocal) + { + DateTime? fromUtc = null; + DateTime? toUtc = null; + + if (fromLocal.HasValue) + { + var localFrom = DateTime.SpecifyKind( + fromLocal.Value, + DateTimeKind.Unspecified); + + fromUtc = TimeZoneInfo.ConvertTimeToUtc( + localFrom, + VancouverTimeZone); + } + + if (toLocal.HasValue) + { + // End of local day (23:59:59.9999999) + var localToEndOfDay = DateTime.SpecifyKind( + toLocal.Value.Date.AddDays(1).AddTicks(-1), + DateTimeKind.Unspecified); + + toUtc = TimeZoneInfo.ConvertTimeToUtc( + localToEndOfDay, + VancouverTimeZone); + } + + return (fromUtc, toUtc); + } + public Task GetPaymentRequestCountBySiteIdAsync(Guid siteId) { return paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); @@ -80,7 +119,7 @@ public async Task> GetPaymentRequestsByIdsAsync(List return await paymentRequestRepository.GetListAsync(x => paymentRequestIds.Contains(x.Id), includeDetails: includeDetails); } - public async Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting, IReadOnlyList? requestedFields = null) + public async Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting, IReadOnlyList? requestedFields = null, DateTime? requestedFromDate = null, DateTime? requestedToDate = null) { var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); var includeSite = IncludesAny(requestedFields, SiteFields); @@ -90,6 +129,16 @@ public async Task> GetPagedPaymentRequestsWithIncludesAsync paymentsQueryable = paymentsQueryable.AsNoTracking(); + var (fromUtc, toUtc) = ConvertToUtcRange(requestedFromDate, requestedToDate); + if (fromUtc.HasValue) + { + paymentsQueryable = paymentsQueryable.Where(pr => pr.CreationTime >= fromUtc.Value); + } + if (toUtc.HasValue) + { + paymentsQueryable = paymentsQueryable.Where(pr => pr.CreationTime <= toUtc.Value); + } + if (includeSite) { paymentsQueryable = paymentsQueryable.Include(pr => pr.Site); @@ -466,4 +515,4 @@ public async Task> GetApplicationP return result; } } -} +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index df3bde3fee..6b3670d4cb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -336,7 +336,9 @@ public async Task> GetListAsync(PaymentRequest input.SkipCount, input.MaxResultCount, input.Sorting ?? string.Empty, - input.RequestedFields); + input.RequestedFields, + input.RequestedFromDate, + input.RequestedToDate); var mappedPayments = await paymentRequestQueryManager.MapToDtoAndLoadDetailsAsync( paymentWithIncludes, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.cshtml index 3a35ebedd1..0af601c312 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.cshtml @@ -84,7 +84,7 @@ -
    +
    diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs index 79fbb38582..8e559f42e0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs @@ -18,7 +18,8 @@ public ActionResult OnGet(string casResponse) string pattern = ";"; string replace = "
    "; - string formattedResponse = Regex.Replace(casResponse, + string encoded = System.Net.WebUtility.HtmlEncode(casResponse); + string formattedResponse = Regex.Replace(encoded, pattern, replace, RegexOptions.None, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml index ba5d3c1f8a..4af9908876 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml @@ -17,8 +17,8 @@ } -
    - @await Component.InvokeAsync("PaymentActionBar") +
    + @await Component.InvokeAsync("PaymentActionBar", new { showDateRangeFilter = true })
    diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css index 814119e4a9..b9c1c99a52 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css @@ -1,4 +1,45 @@ -#PaymentRequestListTable_filter label { +.date-input-filter-div { + display: inline-block; + padding: 10px; + padding-top: 0px; + margin-top: -10px; + margin-bottom: -10px; + padding-bottom: 0px !important; +} + +.date-input-filter-div label.form-label { + font-size: 13.6px; + margin-bottom: 0.3rem; +} + +.custom-date-range-container-div { + display: inline-block; + padding: 0px; + margin: 0px; +} + +.quick-date-input { + font-size: var(--bc-font-size); + color: var(--bc-colors-grey-text-500); + border-radius: var(--bc-layout-margin-small) !important; + border: 2px solid var(--bc-colors-blue-primary); + text-overflow: ellipsis; +} + +.action-bar-date-controls .search-action-bar { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.action-bar-date-controls .date-input-filter-div { + margin-top: 10px; +} + +.action-bar-date-controls #dynamicButtonContainerId { + margin-top: 0; +} + +#PaymentRequestListTable_filter label { font-size: var(--bc-font-size-sm); color: var(--bc-colors-grey-text-300); white-space: nowrap; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index ab6f6489c7..7239d4cb53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -2,12 +2,168 @@ $(function () { const l = abp.localization.getResource('Payments'); const nullPlaceholder = '—'; const requestedFieldsStorageKey = 'PaymentRequests_RequestedFields'; + const defaultQuickDateRange = 'last6months'; const formatter = createNumberFormatter(); const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; let dt = $('#PaymentRequestListTable'); let dataTable; let isApprove = false; + let paymentTableFilters = { + requestedFromDate: null, + requestedToDate: null + }; + + const UIElements = { + quickDateRange: $('#quickDateRange'), + inputFilter: $('.date-input-filter'), + requestedToInput: $('#requestedToDate'), + requestedFromInput: $('#requestedFromDate'), + }; + + function toggleCustomDateInputs(show) { + if (show) { + $('#customDateInputs').show(); + } else { + $('#customDateInputs').hide(); + } + } + + function setDateRangeFilters(quickDateRange, range) { + UIElements.quickDateRange.val(quickDateRange); + + if (range) { + const fromDate = range.fromDate ?? ''; + const toDate = range.toDate ?? ''; + UIElements.requestedFromInput.val(fromDate); + UIElements.requestedToInput.val(toDate); + paymentTableFilters.requestedFromDate = fromDate; + paymentTableFilters.requestedToDate = toDate; + } + } + + function setDateRangeLocalStorage(quickDateRange, fromToRange) { + localStorage.setItem('PaymentRequests_QuickRange', quickDateRange || defaultQuickDateRange); + if (fromToRange) { + const fromDate = fromToRange.fromDate; + const toDate = fromToRange.toDate; + if (fromDate) { + localStorage.setItem('PaymentRequests_FromDate', fromDate); + } + else { + localStorage.removeItem('PaymentRequests_FromDate'); + } + if (toDate) { + localStorage.setItem('PaymentRequests_ToDate', toDate); + } + else { + localStorage.removeItem('PaymentRequests_ToDate'); + } + } + } + + function initializeRequestedFilterDates() { + let savedQuickRange = localStorage.getItem('PaymentRequests_QuickRange') || defaultQuickDateRange; + let savedFromDate = localStorage.getItem('PaymentRequests_FromDate'); + let savedToDate = localStorage.getItem('PaymentRequests_ToDate'); + + let isCustomRange = savedQuickRange === 'custom'; + toggleCustomDateInputs(isCustomRange); + + let range = isCustomRange + ? { + fromDate: savedFromDate || '', + toDate: savedToDate || '' + } + : getDateRange(savedQuickRange); + + if (!isCustomRange && !range) { + savedQuickRange = defaultQuickDateRange; + range = getDateRange(savedQuickRange); + } + + setDateRangeFilters(savedQuickRange, range); + setDateRangeLocalStorage(savedQuickRange, range); + + // Set max date to today for both inputs + const today = formatDate(new Date()); + UIElements.requestedToInput.attr({ 'max': today }); + UIElements.requestedFromInput.attr({ 'max': today }); + } + + function handleInputFilterChange() { + const $input = $(this); + const dateValue = $input.val(); + + if (!validateDate(dateValue, $input)) return; + + paymentTableFilters.requestedFromDate = UIElements.requestedFromInput.val(); + paymentTableFilters.requestedToDate = UIElements.requestedToInput.val(); + + // If the values for FromDate and ToDate are being set outside of the + // quick drop down handler, custom SHOULD be shown, but set just in case + UIElements.quickDateRange.val('custom'); + localStorage.setItem('PaymentRequests_QuickRange', 'custom'); + + localStorage.setItem('PaymentRequests_FromDate', paymentTableFilters.requestedFromDate); + localStorage.setItem('PaymentRequests_ToDate', paymentTableFilters.requestedToDate); + + dataTable.ajax.reload(null, true); + } + + function handleQuickDateRangeChange() { + const selectedRange = $(this).val(); + + if (selectedRange === 'custom') { + // Show the custom date inputs and don't modify their values + toggleCustomDateInputs(true); + return; + } + + // Hide custom date inputs for preset ranges + toggleCustomDateInputs(false); + + // Get the date range for the selected option + const range = getDateRange(selectedRange); + setDateRangeFilters(selectedRange, range); + setDateRangeLocalStorage(selectedRange, range); + + // Reload the table with new filters + dataTable.ajax.reload(null, true); + } + + function bindUIEvents() { + UIElements.inputFilter.on('change', handleInputFilterChange); + UIElements.quickDateRange.on('change', handleQuickDateRangeChange); + } + + // Restores search value and date range filters when a saved view is loaded. + function restoreCustomFilters(filters) { + $('#search').val(filters.externalSearchValue || ''); + + let quickRange = filters.quickDateRange || defaultQuickDateRange; + let isCustomRange = filters.quickDateRange === 'custom'; + toggleCustomDateInputs(isCustomRange); + + let range = isCustomRange + ? { + fromDate: filters.requestedFromDate || '', + toDate: filters.requestedToDate || '' + } + : getDateRange(quickRange); + + if (!isCustomRange && !range) { + quickRange = defaultQuickDateRange; + range = getDateRange(quickRange); + } + + setDateRangeFilters(quickRange, range); + setDateRangeLocalStorage(quickRange, range); + } + + bindUIEvents(); + initializeRequestedFilterDates(); + const listColumns = getColumns(); const defaultVisibleColumns = [ 'select', @@ -87,10 +243,10 @@ $(function () { .done(() => { abp.notify.success('The Status Check has been sent for verification to CFS. Please refresh this page to check for Status updates.'); $(".select-all-payments").prop("checked", false); - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }) @@ -159,10 +315,10 @@ $(function () { .then(function () { abp.notify.success('Payment has been cancelled successfully.'); $(".select-all-payments").prop("checked", false); - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); dataTable.ajax.reload(null, false); @@ -247,7 +403,16 @@ $(function () { $('.dt-search input').val(''); $('#search').val(''); - dt.search('').order(initialSortOrder).draw(); + dt.search('').order(initialSortOrder); + + // Reset date range filters + const range = getDateRange(defaultQuickDateRange); + setDateRangeFilters(defaultQuickDateRange, range); + setDateRangeLocalStorage(defaultQuickDateRange, range); + toggleCustomDateInputs(false); + + // Reload table data with updated filters + dt.ajax.reload(null, false); } }, { extend: 'removeAllStates', text: 'Delete All Views' }, @@ -332,7 +497,9 @@ $(function () { } return { - requestedFields: requestedFields + requestedFields: requestedFields, + requestedFromDate: paymentTableFilters.requestedFromDate, + requestedToDate: paymentTableFilters.requestedToDate }; }, responseCallback, @@ -347,14 +514,17 @@ $(function () { fixedHeaders: true, onStateSaveParams: function (settings, data) { data.customFilters = { - externalSearchValue: $('#search').val() || '' + externalSearchValue: $('#search').val() || '', + quickDateRange: UIElements.quickDateRange.val(), + requestedFromDate: UIElements.requestedFromInput.val(), + requestedToDate: UIElements.requestedToInput.val() }; }, onStateLoadParams: function (settings, data) { if (!initialLoad) { isRestoringState = true; if (data?.customFilters) { - $('#search').val(data.customFilters.externalSearchValue || ''); + restoreCustomFilters(data.customFilters); } } }, @@ -413,10 +583,7 @@ $(function () { ? dataTable.buttons(['.payment-cancel']) : null; - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + checkActionButtons(); dataTable.on('search.dt', () => handleSearch()); function checkAllRowsHaveState(states) { @@ -492,51 +659,36 @@ $(function () { } function checkActionButtons() { - let isInSentState = checkAllRowsHaveState(['Submitted', 'FSB']); - if (isInSentState) { - payment_check_status_buttons.enable(); - } else { - payment_check_status_buttons.disable(); - } + const hasSelection = dataTable.rows({ selected: true }).indexes().length > 0; + let isInSentState = hasSelection && checkAllRowsHaveState(['Submitted', 'FSB']); + setActionButtonState(payment_check_status_buttons, isInSentState); + let hasHistoricalPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'HistoricalPayment'); let hasCancelledPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'Cancelled'); - const hasSelection = dataTable.rows({ selected: true }).indexes().length > 0; const canApprove = hasSelection && !isInSentState && !hasHistoricalPayment && !hasCancelledPayment && (abp.auth.isGranted('PaymentsPermissions.Payments.L1ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L2ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L3ApproveOrDecline')); - if (canApprove) { - payment_approve_buttons.enable(); - } else { - payment_approve_buttons.disable(); - } + setActionButtonState(payment_approve_buttons, canApprove); + checkEnableHistoryButton(dataTable, history_button); if (cancel_button) { const eligibleCancelStatuses = ['HistoricalPayment', 'L1Pending', 'L2Pending', 'L3Pending']; const selectedCount = dataTable.rows({ selected: true }).indexes().length; + let canCancel = false; if (selectedCount === 1) { const rowData = dataTable.rows({ selected: true }).data().toArray()[0]; - if (eligibleCancelStatuses.includes(rowData.status)) { - cancel_button.enable(); - } else { - cancel_button.disable(); - } - } else { - cancel_button.disable(); + canCancel = eligibleCancelStatuses.includes(rowData.status); } + setActionButtonState(cancel_button, canCancel); } } function handleSearch() { - let filterValue = $('.dt-search input').val(); - if (filterValue !== undefined && filterValue.length > 0) { - Array.from(document.getElementsByClassName('selected')).forEach( - function (element, index, array) { - element.classList.toggle('selected'); - } - ); - PubSub.publish("deselect_batchpayment_application", "reset_data"); + const filterValue = dataTable.search(); + if (filterValue?.length > 0) { + dataTable.rows({ selected: true }).deselect(); } } @@ -837,7 +989,14 @@ $(function () { index: columnIndex, render: function (data) { if (data + "" !== "undefined" && data?.length > 0) { - return ''; + const escaped = data + .replaceAll('\\', String.raw`\\`) + .replaceAll("'", String.raw`\'`) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + return ''; } return null; } @@ -1052,10 +1211,10 @@ $(function () { ); dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }); @@ -1112,10 +1271,10 @@ $(function () { (msg, data) => { dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); PubSub.publish('clear_selected_payment'); @@ -1124,6 +1283,98 @@ $(function () { }); +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// Returns a formatted { fromDate, toDate } for the filter fields. +// Null if 'custom' or no input provided (assumes custom is default break) +function getDateRange(rangeType) { + let today = new Date(); + const toDate = formatDate(new Date()); + let fromDate; + + switch (rangeType) { + case 'today': + fromDate = toDate; + break; + case 'last7days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 7))); + break; + case 'last30days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 30))); + break; + case 'last3months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 3))); + break; + case 'last6months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 6))); + break; + case 'currentfiscalyear': { + const currentMonth = today.getMonth(); + const currentYear = today.getFullYear(); + const fiscalStartYear = currentMonth >= 3 ? currentYear : currentYear - 1; + return { + fromDate: formatDate(new Date(fiscalStartYear, 3, 1)), + toDate: formatDate(new Date(fiscalStartYear + 1, 2, 31)) + }; + } + case 'alltime': + return { fromDate: null, toDate: null }; + case 'custom': + default: + return null; // Don't modify dates for custom + } + + return { fromDate, toDate }; +} + +function validateDate(dateValue, element) { + if (dateValue) { + const selectedDate = new Date(dateValue); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const minDate = element.attr('min') ? new Date(element.attr('min')) : null; + const maxDate = element.attr('max') ? new Date(element.attr('max')) : null; + + if (selectedDate > today) { + element.addClass('input-validation-error'); + abp.notify.error('The date cannot be in the future', 'Invalid Date'); + return false; + } + + if (minDate && selectedDate < minDate) { + element.addClass('input-validation-error'); + abp.notify.error('The date cannot be before the minimum allowed date', 'Invalid Date'); + return false; + } + + if (maxDate && selectedDate > maxDate) { + element.addClass('input-validation-error'); + abp.notify.error('The date cannot be after the maximum allowed date', 'Invalid Date'); + return false; + } + + element.removeClass('input-validation-error'); + return true; + } + return true; +} + +function setActionButtonState(buttonApi, enabled) { + if (!buttonApi) return; + if (enabled) { + buttonApi.enable(); + } else { + buttonApi.disable(); + } + buttonApi.nodes().toggleClass('action-bar-btn-unavailable', !enabled); +} + function getCancelledColumn(columnIndex) { return { title: 'Cancelled', @@ -1168,11 +1419,13 @@ let casPaymentResponseModal = new abp.ModalManager({ }); function checkEnableHistoryButton(dataTable, history_button) { - if (dataTable.rows({ selected: true }).indexes().length == 1) { + const enabled = dataTable.rows({ selected: true }).indexes().length == 1; + if (enabled) { history_button.enable(); } else { history_button.disable(); } + history_button.nodes().toggleClass('action-bar-btn-unavailable', !enabled); } function openCasResponseModal(casResponse) { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml index 5e8eb59e59..2d251fbacc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml @@ -1,11 +1,55 @@ @using Unity.Modules.Shared @using Volo.Abp.Authorization.Permissions +@model bool @inject IPermissionChecker PermissionChecker
    + + @if (Model) + { +
    + + +
    + + }
    @@ -14,10 +58,10 @@ }
    -
    \ No newline at end of file +
    diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css index 6e5ca5104a..595b912db9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css @@ -24,6 +24,10 @@ .dynamic-buttons-div{ display:inline-flex; } + +.action-bar-btn-unavailable { + display: none; +} input[type=search]::-webkit-search-cancel-button { -webkit-appearance: button !important; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js index be6bdff0a3..5463e09bee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js @@ -76,7 +76,7 @@ $(function () { let groupedValues = Object.values(groupedTags); if (groupedValues.length > 0) { - commonTags = groupedValues.reduce(filterCommonTags); + commonTags = groupedValues.reduce((prev, next) => filterCommonTags(prev, next), groupedValues[0]); } let allTagEntries = Object.entries(groupedTags).map(([paymentId, tagList]) => { @@ -150,20 +150,23 @@ $(function () { manageActionButtons(); }); + // Owns only the standalone TAGS button. The DataTables-driven buttons + // (Check Status, Approve, Decline, Cancel, History) are exclusively + // owned by checkActionButtons() in PaymentRequests/Index.js — touching + // them here would race with that logic, since PubSub.publish() defers + // delivery to a later tick and could re-enable/unhide a button that + // Index.js just disabled for a selected row. function manageActionButtons() { - if (selectedPaymentIds.length == 0) { - $('*[data-selector="batch-payment-table-actions"]').prop('disabled', true); - $('*[data-selector="batch-payment-table-actions"]').addClass('action-bar-btn-unavailable'); - $('.action-bar').addClass('disabled'); - $('#tagPayment').prop('disabled', true); - } - else { - $('*[data-selector="batch-payment-table-actions"]').prop('disabled', false); - $('*[data-selector="batch-payment-table-actions"]').removeClass('action-bar-btn-unavailable'); - $('.action-bar').addClass('active'); - $('#tagPayment').removeClass('disabled'); - $('#tagPayment').prop('disabled', false); - } + const hasSelection = selectedPaymentIds.length > 0; + + $('#tagPayment') + .prop('disabled', !hasSelection) + .toggleClass('action-bar-btn-unavailable', !hasSelection) + .toggleClass('disabled', !hasSelection); + + $('.action-bar') + .toggleClass('active', hasSelection) + .toggleClass('disabled', !hasSelection); } $('#tagPayment').on('click', function () { @@ -192,5 +195,8 @@ $(function () { manageActionButtons(); PubSub.publish("refresh_payment_list"); }); + + // Initialize button states + manageActionButtons(); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs index 1a58e3bcf5..bef6fe1213 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs @@ -13,9 +13,9 @@ namespace Unity.Payments.Web.Views.Shared.Components.ActionBar AutoInitialize = true)] public class PaymentActionBar : AbpViewComponent { - public IViewComponentResult Invoke() + public IViewComponentResult Invoke(bool showDateRangeFilter = false) { - return View(); + return View(showDateRangeFilter); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index cd3cbfe767..7e70f1d011 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -437,7 +437,14 @@ index: 8, render: function (data) { if (data + '' !== 'undefined' && data?.length > 0) { - return ''; + const escaped = data + .replaceAll('\\', String.raw`\\`) + .replaceAll("'", String.raw`\'`) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + return ''; } return null; }, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs index f4e2af138f..fe4c27b270 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs @@ -410,4 +410,4 @@ private static PaymentRequestQueryManager CreateManager(IPaymentRequestRepositor } #endregion -} +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs index d6e91808e7..0819c74972 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Permissions; using Volo.Abp.UI.Navigation; @@ -31,16 +32,15 @@ public async Task ConfigureMenuAsync(MenuConfigurationContext context) /// /// The menu configuration context for adding reporting menu items. /// A completed task representing the synchronous menu item addition operations. - private static Task ConfigureReportingMenuAsync(MenuConfigurationContext context) + private static async Task ConfigureReportingMenuAsync(MenuConfigurationContext context) { // Add Reporting Configuration menu item for IT Admin users - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( ReportingMenus.Prefix, displayName: "Reporting", - "~/ReportingAdmin/Configuration", - requiredPermissionName: IdentityConsts.ITAdminPermissionName - )); - return Task.CompletedTask; + "~/ReportingAdmin/Configuration") + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName) + ); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs index 2543ae69a0..702c3bbb60 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs @@ -114,8 +114,10 @@ public async Task InvokeAsync(Guid formId, Guid? selectedV FormVersions = [.. formVersions .Select(v => new SelectListItem { - Value = v.Id.ToString(), - Text = $"{v.Version} - {v.ChefsFormVersionGuid!.ToString()}" + Value = v.Id.ToString(), + Text = string.IsNullOrEmpty(v.ChefsFormVersionGuid) + ? $"{v.Version}" + : $"{v.Version} - {v.ChefsFormVersionGuid}" })], SelectedVersionId = selectedVersionId, ViewName = viewName, diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnityAlertConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnityAlertConstants.cs new file mode 100644 index 0000000000..4bdadcd654 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnityAlertConstants.cs @@ -0,0 +1,13 @@ +using System; + +namespace Unity.Modules.Shared.Constants; + +public static class UnityAlertConstants +{ + // Well-known fixed GUID for the Unity Alert (external Teams notification) Person record (host-level, no tenant) + public static readonly Guid UnityAlertPersonId = new("00000000-0000-0000-0000-000000000003"); + public const string UnityAlertOidcSub = "unity-alert"; + public const string UnityAlertUserName = "UALERT"; + public const string UnityAlertName = "Unity Notifications - External: Unity Team - Grant Management"; + public const string UnityAlertEmail = "7852c6bd.bcgov.onmicrosoft.com@ca.teams.ms"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs index 4c2a541385..ebf06de147 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs @@ -1,8 +1,11 @@ +using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Unity.Modules.Shared.Specializations; +using Volo.Abp.Authorization.Permissions; using Volo.Abp.Features; using Volo.Abp.UI.Navigation; +using Volo.Abp.Users; namespace Unity.Modules.Shared.Navigation; @@ -12,6 +15,9 @@ public static class MenuItemExtensions private const string OnlyWhenFeaturesKey = "_OnlyWhenFeatures"; private const string ExcludeWhenSpecializationsKey = "_ExcludeWhenSpecializations"; private const string OnlyWhenSpecializationsKey = "_OnlyWhenSpecializations"; + private const string OnlyWhenInRoleKey = "_OnlyWhenInRole"; + private const string RequiredPermissionOrRolePermissionKey = "_RequiredPermissionOrRolePermission"; + private const string RequiredPermissionOrRoleRolesKey = "_RequiredPermissionOrRoleRoles"; /// /// Hides this menu item when any of the given features are enabled. @@ -58,21 +64,87 @@ public static ApplicationMenuItem OnlyWhenSpecializations( } /// - /// Adds the item to the menu, respecting any feature or specialization visibility declarations. + /// Shows this menu item only when the current user is in any of the given roles + /// (checked via ICurrentUser.IsInRole, e.g. Keycloak-issued client roles). + /// + public static ApplicationMenuItem OnlyWhenInRole( + this ApplicationMenuItem item, + params string[] roleNames) + { + item.CustomData[OnlyWhenInRoleKey] = roleNames; + return item; + } + + /// + /// Shows this menu item when the current user either has the given permission granted + /// (checked via IPermissionChecker, e.g. permissions granted through the DB/UI) or is in + /// any of the given roles (checked via ICurrentUser.IsInRole, e.g. Keycloak-issued client + /// roles). + /// + /// + /// Use this instead of the ApplicationMenuItem(..., requiredPermissionName: ...) + /// constructor argument when a role (typically ITAdministrator/ITOperations) should also be + /// able to see the item even without the permission explicitly granted. The constructor arg + /// is checked entirely inside ABP's own menu-rendering pipeline via IPermissionChecker and + /// has no awareness of roles or of authorization policies - it will NOT be satisfied by a + /// role-based RoleOrPermissionRequirement authorization policy registered for the same + /// permission name, even if that policy protects the page itself. + /// (This is exactly what caused the TestingPermissions menu item to stay hidden for + /// ITOperations/ITAdministrator users despite the page being reachable via a + /// RoleOrPermissionRequirement policy of the same name - the menu check and the page's + /// [Authorize] check are two unrelated code paths.) + /// Use instead when the item should be role-gated ONLY, with no + /// permission fallback. + /// + public static ApplicationMenuItem RequirePermissionOrRole( + this ApplicationMenuItem item, + string permissionName, + params string[] roleNames) + { + item.CustomData[RequiredPermissionOrRolePermissionKey] = permissionName; + item.CustomData[RequiredPermissionOrRoleRolesKey] = roleNames; + return item; + } + + /// + /// Adds the item to the menu, respecting any feature, specialization or role visibility declarations. /// public static async Task AddItemAsync( this MenuConfigurationContext context, ApplicationMenuItem item) { - var featureChecker = context.ServiceProvider.GetRequiredService(); - var specializationChecker = context.ServiceProvider.GetRequiredService(); + if (await IsVisibleAsync(item, context.ServiceProvider)) + { + context.Menu.AddItem(item); + } + } + + /// + /// Adds the item as a child of the given parent menu item, respecting any feature, + /// specialization or role visibility declarations. + /// + public static async Task AddItemAsync( + this ApplicationMenuItem parent, + IServiceProvider serviceProvider, + ApplicationMenuItem item) + { + if (await IsVisibleAsync(item, serviceProvider)) + { + parent.AddItem(item); + } + } + + private static async Task IsVisibleAsync(ApplicationMenuItem item, IServiceProvider serviceProvider) + { + var featureChecker = serviceProvider.GetRequiredService(); + var specializationChecker = serviceProvider.GetRequiredService(); if (item.CustomData.TryGetValue(ExcludeWhenFeaturesKey, out var excludeFeatObj) && excludeFeatObj is string[] excludeFeatures) { foreach (var feature in excludeFeatures) if (await featureChecker.IsEnabledAsync(feature)) - return; + return false; } if (item.CustomData.TryGetValue(OnlyWhenFeaturesKey, out var onlyFeatObj) @@ -80,7 +152,7 @@ public static async Task AddItemAsync( { foreach (var feature in onlyFeatures) if (!await featureChecker.IsEnabledAsync(feature)) - return; + return false; } if (item.CustomData.TryGetValue(ExcludeWhenSpecializationsKey, out var excludeSpecObj) @@ -88,7 +160,7 @@ public static async Task AddItemAsync( { foreach (var spec in excludeSpecs) if (await specializationChecker.IsEnabledAsync(spec)) - return; + return false; } if (item.CustomData.TryGetValue(OnlyWhenSpecializationsKey, out var onlySpecObj) @@ -96,9 +168,34 @@ public static async Task AddItemAsync( { foreach (var spec in onlySpecs) if (!await specializationChecker.IsEnabledAsync(spec)) - return; + return false; + } + + if (item.CustomData.TryGetValue(OnlyWhenInRoleKey, out var onlyRoleObj) + && onlyRoleObj is string[] onlyRoles) + { + var currentUser = serviceProvider.GetRequiredService(); + if (!Array.Exists(onlyRoles, currentUser.IsInRole)) + return false; + } + + if (item.CustomData.TryGetValue(RequiredPermissionOrRolePermissionKey, out var permObj) + && permObj is string permissionName) + { + var roles = item.CustomData.TryGetValue(RequiredPermissionOrRoleRolesKey, out var rolesObj) + && rolesObj is string[] requiredRoles + ? requiredRoles + : []; + + var currentUser = serviceProvider.GetRequiredService(); + if (!Array.Exists(roles, currentUser.IsInRole)) + { + var permissionChecker = serviceProvider.GetRequiredService(); + if (!await permissionChecker.IsGrantedAsync(permissionName)) + return false; + } } - context.Menu.AddItem(item); + return true; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs index 1a4b1583e9..a9fc9c2537 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs @@ -10,5 +10,9 @@ public static class IdentityConsts public const string ITOperationsRoleName = "ITOperations"; public const string ITOperationsPermissionName = "ITOperations"; + // ITAdministrator is a superset of ITOperations - anywhere ITOperations alone is + // required, ITAdministrator should also be allowed. Use this policy (RequireRole is an + // OR check across the given roles) instead of ITOperationsPolicyName when that's needed. + public const string ITAdminOrITOperationsPolicyName = "ITAdminOrITOperations"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs index 86466a1d01..b2e6555923 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs @@ -5,8 +5,10 @@ namespace Unity.Modules.Shared.Utils; public static class DateTimeExtensions { - // BC Pacific timezone: PST does NOT observe DST in 2026 — fixed UTC-8 year-round. - private static readonly TimeSpan BcPstOffset = TimeSpan.FromHours(-8); + // BC Pacific timezone: PST/PDT depending on time of year. + private const string WindowsPacificId = "Pacific Standard Time"; + private const string IanaPacificId = "America/Vancouver"; + private static readonly Lazy PacificTimeZone = new(GetPacificTimeZone, isThreadSafe: true); // BC Mountain timezone: Peace River / NE BC region — MST/MDT, DST still applies. private const string WindowsMountainId = "Mountain Standard Time"; @@ -29,13 +31,11 @@ public static string FormatTimestamp(DateTime? utcTime) } /// - /// Converts a given UTC time to BC Pacific Standard Time and formats it as a string. - /// BC's Pacific timezone does NOT observe Daylight Saving Time in 2026; PST (UTC-8) - /// is applied year-round. For the Peace River / NE BC region (Mountain Time), use - /// instead. + /// Converts a given UTC time to BC Pacific Time and formats it as a string. + /// Added support for historic PST rendering. /// /// The UTC time to convert. If , an empty string is returned. - /// A string formatted as "yyyy-MM-dd h:mm tt (PST)". + /// A string formatted as "yyyy-MM-dd h:mm tt (PST)" or "yyyy-MM-dd h:mm tt (PDT)". public static string FormatPacificTime(DateTime? utcTime) { if (!utcTime.HasValue) @@ -45,10 +45,11 @@ public static string FormatPacificTime(DateTime? utcTime) ? utcTime.Value : DateTime.SpecifyKind(utcTime.Value, DateTimeKind.Utc); - // BC PST is a fixed UTC-8 offset — no DST adjustment in 2026. - var bcPstDateTime = new DateTimeOffset(utcTimeValue, TimeSpan.Zero).ToOffset(BcPstOffset); + var pacificTz = PacificTimeZone.Value; + var ptDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcTimeValue, pacificTz); + string abbr = pacificTz.IsDaylightSavingTime(ptDateTime) ? "(PDT)" : "(PST)"; - return $"{bcPstDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} (PST)"; + return $"{ptDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {abbr}"; } /// @@ -74,6 +75,23 @@ public static string FormatMountainTime(DateTime? utcTime) return $"{mtDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {abbr}"; } + private static TimeZoneInfo GetPacificTimeZone() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (TryFindTimeZone(WindowsPacificId, out var tz)) return tz; + if (TryFindTimeZone(IanaPacificId, out tz)) return tz; + } + else + { + if (TryFindTimeZone(IanaPacificId, out var tz)) return tz; + if (TryFindTimeZone(WindowsPacificId, out tz)) return tz; + } + + throw new TimeZoneNotFoundException( + $"Neither '{WindowsPacificId}' nor '{IanaPacificId}' time zone IDs were found on this system."); + } + private static TimeZoneInfo GetMountainTimeZone() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs index ac60ae4cee..911718d322 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs @@ -18,10 +18,12 @@ public static class AbpUserTenantAccessor var surname = currentUser.SurName; if (!string.IsNullOrWhiteSpace(given) || !string.IsNullOrWhiteSpace(surname)) { - return $"{given} {surname}".Trim(); + var fullName = $"{given} {surname}".Trim(); + if (!string.IsNullOrWhiteSpace(fullName)) return fullName; } - return currentUser.UserName; + var userName = currentUser.UserName; + return string.IsNullOrWhiteSpace(userName) ? null : userName; } public static async Task GetCurrentTenantNameAsync(IServiceProvider serviceProvider) @@ -82,5 +84,11 @@ public static class AbpUserTenantAccessor return null; } + + public static Guid? GetCurrentUserId(IServiceProvider serviceProvider) + { + var currentUser = serviceProvider.GetService(); + return currentUser?.Id; + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs index 54c9e44dea..9915343860 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs @@ -1,18 +1,18 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Permissions; using Volo.Abp.TenantManagement.Localization; using Volo.Abp.UI.Navigation; -using Volo.Abp.Authorization.Permissions; namespace Unity.TenantManagement.Web.Navigation; public class AbpTenantManagementWebMainMenuContributor : IMenuContributor { - public virtual Task ConfigureMenuAsync(MenuConfigurationContext context) + public virtual async Task ConfigureMenuAsync(MenuConfigurationContext context) { if (context.Menu.Name != StandardMenus.Main) { - return Task.CompletedTask; + return; } var administrationMenu = context.Menu.GetAdministration(); @@ -22,11 +22,10 @@ public virtual Task ConfigureMenuAsync(MenuConfigurationContext context) var tenantManagementMenuItem = new ApplicationMenuItem(TenantManagementMenuNames.GroupName, l["Menu:TenantManagement"], icon: "fa fa-users"); administrationMenu.AddItem(tenantManagementMenuItem); - tenantManagementMenuItem.AddItem( + await tenantManagementMenuItem.AddItemAsync( + context.ServiceProvider, new ApplicationMenuItem(TenantManagementMenuNames.Tenants, l["Tenants"], url: "~/TenantManagement/Tenants") - .RequirePermissions(TenantManagementPermissions.Tenants.Default, IdentityConsts.ITOperationsPermissionName) + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName) ); - - return Task.CompletedTask; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs index 3f5c9cd4c2..6966884120 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs @@ -57,7 +57,7 @@ public virtual async Task OnGetAsync(Guid id) .AuthorizeAsync(User, TenantManagementPermissions.Tenants.ManageConnectionStrings)).Succeeded; CanManageFeatures = (await AuthorizationService - .AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded; + .AuthorizeAsync(User, IdentityConsts.ITAdminOrITOperationsPolicyName)).Succeeded; CanManageManagers = CanManageFeatures; @@ -91,7 +91,7 @@ await tenantAppService.AssignManagerAsync(new TenantAssignManagerDto } if (!string.IsNullOrEmpty(FeaturesJson) && - (await AuthorizationService.AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded) + (await AuthorizationService.AuthorizeAsync(User, IdentityConsts.ITAdminOrITOperationsPolicyName)).Succeeded) { var featureUpdates = new List(); foreach (var feature in JsonDocument.Parse(FeaturesJson).RootElement.EnumerateArray()) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml index a0178963b9..cc5a9ead15 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml @@ -1,8 +1,10 @@ -@using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.Brand +@using System.Linq +@using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.Brand @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.Menu @using Volo.Abp.Authorization.Permissions @using Volo.Abp.Features @using Volo.Abp.MultiTenancy +@using Volo.Abp.Security.Claims @using Volo.Abp.Users; @inject ICurrentUser CurrentUser @@ -44,13 +46,17 @@ { Applicant Portal Configuration } - @if (CurrentUser.IsInRole("system_admin") && await FeatureChecker.IsEnabledAsync("SettingManagement.Enable")) + @* "system_admin" is a DB-managed role, not a Keycloak-native one - it's only ever + present as an AbpClaimTypes.Role claim (dynamically recomputed by ABP from the DB + on every request), never in client_roles/CurrentUser.IsInRole. *@ + @if (CurrentUser.FindClaims(AbpClaimTypes.Role).Any(c => c.Value == "system_admin") && await FeatureChecker.IsEnabledAsync("SettingManagement.Enable")) { Configuration Management } @if (CurrentUser.IsInRole("ITOperations")) { Unity Admin + Exception Logs } Logout diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css index 418113611f..93e869148d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css @@ -1,5 +1,11 @@ /** UNITY based custom styles */ +@font-face { + font-family: 'Segoe-Fluent-Icons'; + src: url('fonts/icons/Segoe-MDL2-Assets.ttf') format('truetype'); + font-weight: 400; + font-style: normal; +} :root { /* bc style colors */ @@ -377,7 +383,6 @@ td.dt-editable { td.dt-editable:before { font-family: 'Segoe-Fluent-Icons', sans-serif; - src: url('fonts/icons/Segoe-MDL2-Assets.ttf') format('truetype'); font-weight: 100; font-style: normal; font-size: 11px; @@ -395,7 +400,6 @@ td.dt-editable { content: '\E8BB'; color: var(--bc-colors-white-primary-500, #FFF); font-family: 'Segoe-Fluent-Icons', sans-serif; - src: url('fonts/icons/Segoe-MDL2-Assets.ttf') format('truetype'); font-size: 9px; padding: 0px; padding-bottom: 14px; diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/zone-extensions.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/zone-extensions.js index 188fe000fc..038076fa8d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/zone-extensions.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/zone-extensions.js @@ -411,14 +411,13 @@ class UnityZoneForm extends UnityChangeTrackingForm { reportZones(viewExpanded = false) { let tableData = []; - const self = this; // Store reference to the class instance - this.form.find('fieldset').each(function () { - const fieldName = $(this).attr('name'); + this.form.find('fieldset').each((_, fieldset) => { + const fieldName = $(fieldset).attr('name'); - $(this).find(':input').each(function () { - const $el = $(this); - const name = this.name || '(no name)'; + $(fieldset).find(':input').each((_, input) => { + const $el = $(input); + const name = input.name || '(no name)'; // Get current value based on input type let currentValue; @@ -435,21 +434,21 @@ class UnityZoneForm extends UnityChangeTrackingForm { } // Get original value if it exists - const originalValue = name !== '(no name)' && self.originalValues.hasOwnProperty(name) ? - self.originalValues[name] : '(not tracked)'; + const originalValue = name !== '(no name)' && this.originalValues.hasOwnProperty(name) ? + this.originalValues[name] : '(not tracked)'; - const isModified = self.modifiedFields.has(name); + const isModified = this.modifiedFields.has(name); let tableOutput = { - 'fieldsetName': self.#extractZoneSuffix(fieldName), - 'id': this.id + 'fieldsetName': this.#extractZoneSuffix(fieldName), + 'id': input.id } if (viewExpanded) { let expandedProperties = { 'name': name, - 'tag': this.tagName.toLowerCase(), - 'type': this.type + 'tag': input.tagName.toLowerCase(), + 'type': input.type }; tableOutput = { ...tableOutput, ...expandedProperties }; 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 1edd931473..3cc7e220d4 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 @@ -1,6 +1,7 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using System; using System.Threading.Tasks; +using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Forms; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -22,5 +23,6 @@ public interface IApplicationFormVersionAppService : ICrudAppService< Task GetByChefsFormVersionId(Guid chefsFormVersionId); Task GetFormVersionByApplicationIdAsync(Guid applicationId); Task DeleteWorkSheetMappingByFormName(string formName, Guid formVersionId); - } -} + Task GenerateMappingAsync(Guid id); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingDto.cs new file mode 100644 index 0000000000..2c15bbf737 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingDto.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class ApplicationFormMappingDto +{ + public Guid ApplicationFormVersionId { get; set; } + public List CoreFieldMatches { get; set; } = []; + public List WorksheetMatches { get; set; } = []; + public List WorksheetCreationSuggestions { get; set; } = []; + public List Issues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingReadModelDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingReadModelDto.cs new file mode 100644 index 0000000000..cbc00f5272 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingReadModelDto.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class ApplicationFormMappingReadModelDto +{ + public Guid ApplicationFormVersionId { get; set; } + public Guid ApplicationFormId { get; set; } + public string? ChefsApplicationFormGuid { get; set; } + public string? ChefsFormVersionGuid { get; set; } + public string? ExistingMapping { get; set; } + public List ChefsFields { get; set; } = []; + public List UnityCoreFields { get; set; } = []; + public List Worksheets { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingDto.cs new file mode 100644 index 0000000000..2e50566738 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingDto.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class FormMappingDto +{ + public string SourceField { get; set; } = string.Empty; + public string TargetField { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public decimal Confidence { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetDto.cs new file mode 100644 index 0000000000..33bf932d97 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class FormWorksheetDto +{ + public string WorksheetName { get; set; } = string.Empty; + public List FieldMatches { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingFieldDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingFieldDto.cs new file mode 100644 index 0000000000..654f39dba2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingFieldDto.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class MappingFieldDto +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public bool IsCustom { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingIssueDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingIssueDto.cs new file mode 100644 index 0000000000..31c5f1728e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingIssueDto.cs @@ -0,0 +1,7 @@ +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class MappingIssueDto +{ + public string Code { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetCreationSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetCreationSuggestionDto.cs new file mode 100644 index 0000000000..4c574f92aa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetCreationSuggestionDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class WorksheetCreationSuggestionDto +{ + public string WorksheetName { get; set; } = string.Empty; + public List SuggestedFields { get; set; } = []; + public string Reason { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingFieldsDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingFieldsDto.cs new file mode 100644 index 0000000000..070384e7f9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingFieldsDto.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class WorksheetMappingFieldsDto +{ + public Guid WorksheetId { get; set; } + public string WorksheetName { get; set; } = string.Empty; + public List Fields { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs index f229885d76..6dc6843875 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs @@ -5,5 +5,4 @@ public class AIGenerationStatusDto public AIGenerationRequestDto? GenerationRequest { get; set; } public string? FailureReason { get; set; } public bool IsGenerating { get; set; } - public int RetryAfterSeconds { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs index 819e281758..c9ee1cc7af 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs @@ -3,6 +3,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationAnalysisBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } public Guid? RequestedByUserId { get; set; } public string? PromptVersion { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs index 6e59353451..d57e4378fa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs @@ -3,6 +3,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationScoringBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } public Guid? RequestedByUserId { get; set; } public string? PromptVersion { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs index 987a6037b8..746bc729b8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs @@ -6,6 +6,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateAttachmentSummaryBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } public Guid? RequestedByUserId { get; set; } public List? AttachmentIds { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs new file mode 100644 index 0000000000..de81117c29 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs @@ -0,0 +1,17 @@ +using System; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormMappingBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } + + public Guid? TenantId { get; set; } + + public Guid? RequestedByUserId { get; set; } + + public Guid ApplicationFormVersionId { get; set; } + + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs new file mode 100644 index 0000000000..db274be220 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs @@ -0,0 +1,17 @@ +using System; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormWorksheetBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } + + public Guid? TenantId { get; set; } + + public Guid? RequestedByUserId { get; set; } + + public Guid ApplicationFormVersionId { get; set; } + + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IHistoryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IHistoryAppService.cs index f579cfae19..53cc8e132b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IHistoryAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IHistoryAppService.cs @@ -7,6 +7,7 @@ namespace Unity.GrantManager.History { public interface IHistoryAppService : IApplicationService { + Task> GetEntityPropertyChangesAsync(GetEntityPropertyChangesInput input, Dictionary? lookupDictionary = null); Task> GetHistoryList(string? entityId, string filterPropertyName, Dictionary? lookupDictionary); Task LookupUserName(Guid auditLogId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs index 2fa79c7c18..d8d97d9853 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs @@ -64,6 +64,18 @@ public override void Define(IFeatureDefinitionContext context) .Create("AI Scoring"), valueType: new ToggleStringValueType()); + myGroup.AddFeature("Unity.AI.FormMapping", + defaultValue: defaultValue, + displayName: LocalizableString + .Create("AI Form Mapping"), + valueType: new ToggleStringValueType()); + + myGroup.AddFeature("Unity.AI.FormWorksheet", + defaultValue: defaultValue, + displayName: LocalizableString + .Create("AI Form Worksheet"), + valueType: new ToggleStringValueType()); + myGroup.AddFeature("Unity.Analytics", defaultValue: defaultValue, displayName: LocalizableString diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/History/GetEntityPropertyChangesInput.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/History/GetEntityPropertyChangesInput.cs new file mode 100644 index 0000000000..bbf5e9564e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/History/GetEntityPropertyChangesInput.cs @@ -0,0 +1,14 @@ +using System; + +namespace Unity.GrantManager.History; + +public class GetEntityPropertyChangesInput +{ + public string? EntityId { get; set; } + public string? EntityTypeFullName { get; set; } + public string[] PropertyNames { get; set; } = []; + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public int MaxResultCount { get; set; } = 50; + public int SkipCount { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs index 7c233192c8..401b7e8728 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs @@ -16,5 +16,7 @@ public interface IEndpointManagementAppService : ICrudAppService< Task GetChefsApiBaseUrlAsync(); Task GetUrlByKeyNameAsync(string keyName); Task GetUgmUrlByKeyNameAsync(string keyName); + Task GetGitHubRepoUrlAsync(); + Task GetGitHubGraphQlUrlAsync(); Task ClearCacheAsync(Guid? tenantId = null); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/CreateExceptionLogDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/CreateExceptionLogDto.cs new file mode 100644 index 0000000000..16c66e519a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/CreateExceptionLogDto.cs @@ -0,0 +1,36 @@ +using System; + +namespace Unity.GrantManager.Logs; + +public class CreateExceptionLogDto +{ + public Guid? UserId { get; set; } + public string? UserName { get; set; } + public string? TenantName { get; set; } + public ExceptionLogType NotificationType { get; set; } + public ExceptionLogChannel Channel { get; set; } + public ExceptionLogSeverity Severity { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? SourceReference { get; set; } + public string? PayloadJson { get; set; } + public string? CorrelationId { get; set; } + public bool IsDeliveredRealtime { get; set; } + public string? DeliveryTarget { get; set; } + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public string? StackExcerpt { get; set; } + public string? SourceFile { get; set; } + public int? SourceLine { get; set; } + public string? CommitSha { get; set; } + public string? Environment { get; set; } + public string? BlameAuthor { get; set; } + public string? BlameEmail { get; set; } + public string? BlameCommitSha { get; set; } + public string? BlameCommitMessage { get; set; } + public string? PullRequestUrl { get; set; } + public int? PullRequestNumber { get; set; } + public string? PullRequestTitle { get; set; } + public string? TicketReference { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/ExceptionLogDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/ExceptionLogDto.cs new file mode 100644 index 0000000000..2e0d037a2d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/ExceptionLogDto.cs @@ -0,0 +1,37 @@ +using System; + +namespace Unity.GrantManager.Logs; + +public class ExceptionLogDto +{ + public Guid Id { get; set; } + public DateTime CreationTime { get; set; } + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public string? UserName { get; set; } + public string? TenantName { get; set; } + public ExceptionLogType NotificationType { get; set; } + public ExceptionLogChannel Channel { get; set; } + public ExceptionLogSeverity Severity { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? SourceReference { get; set; } + public int OccurrenceCount { get; set; } + public string? CorrelationId { get; set; } + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public string? StackExcerpt { get; set; } + public string? SourceFile { get; set; } + public int? SourceLine { get; set; } + public string? CommitSha { get; set; } + public string? Environment { get; set; } + public string? BlameAuthor { get; set; } + public string? BlameEmail { get; set; } + public string? BlameCommitSha { get; set; } + public string? BlameCommitMessage { get; set; } + public string? PullRequestUrl { get; set; } + public int? PullRequestNumber { get; set; } + public string? PullRequestTitle { get; set; } + public string? TicketReference { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/GetExceptionLogsInput.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/GetExceptionLogsInput.cs new file mode 100644 index 0000000000..6a88a1eaaa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/GetExceptionLogsInput.cs @@ -0,0 +1,12 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Unity.GrantManager.Logs; + +public class GetExceptionLogsInput : PagedAndSortedResultRequestDto +{ + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + public ExceptionLogSeverity? Severity { get; set; } + public string? Filter { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/IExceptionLogAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/IExceptionLogAppService.cs new file mode 100644 index 0000000000..b462393e7f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Logs/IExceptionLogAppService.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.Logs; + +public interface IExceptionLogAppService : IApplicationService +{ + [RemoteService(false)] + Task CreateAsync(CreateExceptionLogDto input); + + Task> GetListAsync(GetExceptionLogsInput input); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs index aedf23e936..24fe0cf5db 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Unity.GrantManager.Notifications.Teams; +using Unity.GrantManager.Notifications.Logs; namespace Unity.GrantManager.Notifications { @@ -8,7 +8,7 @@ public interface INotificationsAppService { Task NotifyChefsEventToTeamsAsync(string factName, string factValue, bool alert = false); Task PostChefsEventToTeamsAsync(string subscriptionEvent, dynamic form, dynamic chefsFormVersion); - Task PostToTeamsAsync(string activityTitle, string activitySubtitle); - Task PostToTeamsAsync(string activityTitle, string activitySubtitle, List facts); + Task PostToNotificationsAsync(string activityTitle, string activitySubtitle); + Task PostToNotificationsAsync(string activityTitle, string activitySubtitle, List facts); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Teams/Facts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Logs/Facts.cs similarity index 84% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Teams/Facts.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Logs/Facts.cs index 4fc1001d1a..ee0fb5677f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Teams/Facts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Logs/Facts.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Unity.GrantManager.Notifications.Teams +namespace Unity.GrantManager.Notifications.Logs { public class Fact { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Logs/NotificationType.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Logs/NotificationType.cs new file mode 100644 index 0000000000..34fcfd2df9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Logs/NotificationType.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.Notifications.Logs +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum NotificationType + { + DatabaseException, + UnityException, + UnityAlert, + UnityNotification, + ChefsEvent + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs index 07f89f02f8..06d1486eaf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs @@ -122,7 +122,7 @@ public override void Define(IPermissionDefinitionContext context) //-- TAG ASSIGNMENT var tagsPermissionsGroup = context.AddGroup("Tags", L("Permission:Tags")); tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Create, L(UnitySelector.Application.Tags.Create)); - tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Delete, L(UnitySelector.Application.Tags.Delete)); + tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Delete, L(UnitySelector.Application.Tags.Delete)); } private static LocalizableString L(string name) 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 29491c9f2f..6a454f0345 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs @@ -21,7 +21,7 @@ using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; using Unity.GrantManager.Notifications; -using Unity.GrantManager.Notifications.Teams; +using Unity.GrantManager.Notifications.Logs; namespace Unity.GrantManager.ApplicationForms { @@ -245,7 +245,7 @@ private async Task ProcessSubmission(ApplicationFormDto applicationFormDto, JObj string activityTitle = "Review Missed Chefs Submissions " + tenantName; string activitySubtitle = "Environment: " + envInfo; - await _notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, _facts); + await _notificationsAppService.PostToNotificationsAsync(activityTitle, activitySubtitle, _facts); } return (missingSubmissions ?? [], missingSubmissionsReportBuilder.ToString()); } 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 63fa4d8cf8..d09c3bb30c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -6,9 +6,17 @@ using System.Collections.Generic; using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Unity.AI.Cooldown; +using Unity.AI.Features; +using Unity.AI.Permissions; +using Unity.AI.Operations; +using Unity.AI.Requests; +using Unity.AI.Responses; +using Unity.AI.Runtime; using Unity.GrantManager.Forms; using Unity.GrantManager.Intakes; using Unity.GrantManager.Integrations.Chefs; +using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Reporting.FieldGenerators; using Unity.Modules.Shared.Features; using Volo.Abp.Application.Dtos; @@ -16,6 +24,7 @@ using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.Features; +using Volo.Abp; using Volo.Abp.Uow; using Unity.GrantManager.Intakes.Mapping; @@ -29,7 +38,10 @@ public class ApplicationFormVersionAppService( IApplicationFormVersionRepository formVersionRepository, IApplicationFormSubmissionRepository formSubmissionRepository, IReportingFieldsGeneratorService reportingFieldsGeneratorService, - IFeatureChecker featureChecker) : + IFeatureChecker featureChecker, + IApplicationFormVersionMappingReadService mappingReadService, + IAICooldownService aiCooldownService, + IFormMappingService aiService) : CrudAppService< ApplicationFormVersion, ApplicationFormVersionDto, @@ -38,6 +50,10 @@ public class ApplicationFormVersionAppService( CreateUpdateApplicationFormVersionDto>(repository), IApplicationFormVersionAppService { + private readonly IApplicationFormVersionMappingReadService _mappingReadService = mappingReadService; + private readonly IAICooldownService _aiCooldownService = aiCooldownService; + private readonly IFormMappingService _aiService = aiService; + public override async Task CreateAsync(CreateUpdateApplicationFormVersionDto input) => await base.CreateAsync(input); @@ -311,6 +327,32 @@ public async Task DeleteWorkSheetMappingByFormName(string formName, Guid formVer await formVersionRepository.UpdateAsync(applicationFormVersion); } + public virtual async Task GenerateMappingAsync(Guid id) + { + if (!await featureChecker.IsEnabledAsync(AIFeatures.FormMapping)) + { + throw new UserFriendlyException("AI form mapping is disabled."); + } + + await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping); + await _aiCooldownService.EnsureAsync(CurrentUser.Id); + + var readModel = await _mappingReadService.GetAsync(id); + var response = await _aiService.GenerateFormMappingAsync(new FormMappingRequest + { + Data = FormMappingPromptDataBuilder.Build(readModel) + }); + var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); + var applicationFormVersion = await repository.GetAsync(id); + applicationFormVersion.SubmissionHeaderMapping = submissionHeaderMapping; + await repository.UpdateAsync(applicationFormVersion, true); + + return new ApplicationFormMappingDto + { + ApplicationFormVersionId = id + }; + } + private async Task GetVersion(Guid formVersionId) { var formVersion = await formVersionRepository.GetByChefsFormVersionAsync(formVersionId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs new file mode 100644 index 0000000000..dcb5214419 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Unity.Flex; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Definitions; +using Unity.Flex.Domain.Worksheets; +using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Intakes; +using Unity.GrantManager.Intakes.Mapping; +using Unity.Modules.Shared.Correlation; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public interface IApplicationFormVersionMappingReadService +{ + Task GetAsync(Guid formVersionId); +} + +public class ApplicationFormVersionMappingReadService( + IRepository applicationFormVersionRepository, + IWorksheetListRepository worksheetListRepository, + IFeatureChecker featureChecker) : IApplicationFormVersionMappingReadService, ITransientDependency +{ + private static readonly HashSet ExcludedMappingFieldNames = new(StringComparer.OrdinalIgnoreCase) + { + nameof(IntakeMapping.ConfirmationId), + nameof(IntakeMapping.SubmissionDate), + nameof(IntakeMapping.SubmissionId) + }; + + public async Task GetAsync(Guid formVersionId) + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId); + + var model = new ApplicationFormMappingReadModelDto + { + ApplicationFormVersionId = formVersion.Id, + ApplicationFormId = formVersion.ApplicationFormId, + ChefsApplicationFormGuid = formVersion.ChefsApplicationFormGuid, + ChefsFormVersionGuid = formVersion.ChefsFormVersionGuid, + ExistingMapping = formVersion.SubmissionHeaderMapping, + ChefsFields = BuildChefsFields(formVersion.AvailableChefsFields), + UnityCoreFields = BuildUnityCoreFields() + }; + + if (await featureChecker.IsEnabledAsync("Unity.Flex")) + { + var worksheets = await worksheetListRepository.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion, includeDetails: true); + model.Worksheets = worksheets.Select((Worksheet worksheet) => MapWorksheet(worksheet)).ToList(); + } + + return model; + } + + private static List BuildChefsFields(string? availableChefsFields) + { + if (string.IsNullOrWhiteSpace(availableChefsFields)) + { + return []; + } + + var jObject = JObject.Parse(availableChefsFields); + return jObject.Properties() + .Where(property => !ExcludedMappingFieldNames.Contains(property.Name)) + .Select(property => + { + var fieldConfig = JObject.Parse(property.Value.ToString()); + return new MappingFieldDto + { + Name = property.Name, + Type = fieldConfig["type"]?.ToString() ?? "String", + IsCustom = false, + Label = fieldConfig["label"]?.ToString() ?? property.Name + }; + }) + .OrderBy(field => field.Label) + .ToList(); + } + + private static List BuildUnityCoreFields() + { + var intakeMapping = new IntakeMapping(); + return intakeMapping.GetType() + .GetProperties() + .Select(property => new + { + Property = property, + Browsable = property.GetCustomAttributes(typeof(BrowsableAttribute), true).Cast().SingleOrDefault(), + DisplayName = property.GetCustomAttributes(typeof(DisplayNameAttribute), true).Cast().SingleOrDefault(), + FieldType = property.GetCustomAttributes(typeof(MapFieldTypeAttribute), true).Cast().SingleOrDefault() + }) + .Where(item => item.Browsable?.IsDefaultAttribute() == true) + .Where(item => !ExcludedMappingFieldNames.Contains(item.Property.Name)) + .Select(item => new MappingFieldDto + { + Name = item.Property.Name, + Type = item.FieldType?.Type ?? "String", + IsCustom = false, + Label = item.DisplayName?.DisplayName ?? item.Property.Name + }) + .OrderBy(field => field.Label) + .ToList(); + } + + private static WorksheetMappingFieldsDto MapWorksheet(Worksheet worksheet) + { + return new WorksheetMappingFieldsDto + { + WorksheetId = worksheet.Id, + WorksheetName = worksheet.Name, + Fields = worksheet.Sections + .SelectMany(section => section.Fields) + .Where(field => IsMappable(field)) + .Select(field => new MappingFieldDto + { + Name = string.IsNullOrWhiteSpace(field.Key) ? field.Name : field.Key, + Type = ConvertCustomType(field.Type), + IsCustom = true, + Label = $"{field.Label} ({worksheet.Name})" + }) + .OrderBy(field => field.Label) + .ToList() + }; + } + + private static bool IsMappable(CustomField? field) + { + if (field == null) + { + return false; + } + + return field.Type switch + { + CustomFieldType.DataGrid => IsDataGridMappable(field), + _ => true + }; + } + + private static bool IsDataGridMappable(CustomField field) + { + if (string.IsNullOrWhiteSpace(field.Definition)) + { + return true; + } + + var definition = (DataGridDefinition?)field.Definition.ConvertDefinition(CustomFieldType.DataGrid); + return definition?.Dynamic ?? true; + } + + private static string ConvertCustomType(CustomFieldType type) => type switch + { + CustomFieldType.Text => "String", + CustomFieldType.Date => "Date", + CustomFieldType.Email => "Email", + CustomFieldType.Phone => "Phone", + CustomFieldType.DateTime => "Date", + CustomFieldType.YesNo => "YesNo", + CustomFieldType.Currency => "Currency", + CustomFieldType.Numeric => "Number", + CustomFieldType.Radio => "Radio", + CustomFieldType.Checkbox => "Checkbox", + CustomFieldType.CheckboxGroup => "CheckboxGroup", + CustomFieldType.SelectList => "SelectList", + CustomFieldType.BCAddress => "BCAddress", + CustomFieldType.TextArea => "TextArea", + CustomFieldType.DataGrid => "DataGrid", + _ => string.Empty + }; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingPromptDataBuilder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingPromptDataBuilder.cs new file mode 100644 index 0000000000..80a83e19eb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingPromptDataBuilder.cs @@ -0,0 +1,51 @@ +using System.Text.Json; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +internal static class FormMappingPromptDataBuilder +{ + internal static JsonElement Build(ApplicationFormMappingReadModelDto readModel) + { + var existingMapping = ParseExistingMapping(readModel.ExistingMapping); + var promptData = new + { + chefsData = new + { + applicationFormId = readModel.ApplicationFormId, + applicationFormVersionId = readModel.ApplicationFormVersionId, + chefsApplicationFormGuid = readModel.ChefsApplicationFormGuid, + chefsFormVersionGuid = readModel.ChefsFormVersionGuid, + fields = readModel.ChefsFields + }, + unityData = new + { + coreFields = readModel.UnityCoreFields, + customFields = readModel.Worksheets + }, + existingMapping + }; + + return JsonSerializer.SerializeToElement(promptData, new JsonSerializerOptions + { + WriteIndented = true + }); + } + + private static JsonElement ParseExistingMapping(string? existingMapping) + { + if (string.IsNullOrWhiteSpace(existingMapping)) + { + return JsonSerializer.SerializeToElement(new { }); + } + + try + { + using var document = JsonDocument.Parse(existingMapping); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return JsonSerializer.SerializeToElement(existingMapping); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs new file mode 100644 index 0000000000..06860cd8a6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using Unity.AI.Responses; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +internal static class FormMappingResponseMapper +{ + internal static string BuildSubmissionHeaderMapping(FormMappingResponse response) + { + if (string.IsNullOrWhiteSpace(response.Mapping)) + { + return "{}"; + } + + try + { + using var document = JsonDocument.Parse(response.Mapping); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return "{}"; + } + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) + { + return "{}"; + } + + var chefsField = property.Value.GetString(); + if (string.IsNullOrWhiteSpace(chefsField) || string.IsNullOrWhiteSpace(property.Name)) + { + return "{}"; + } + } + + return document.RootElement.GetRawText(); + } + catch (JsonException) + { + return "{}"; + } + } +} 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 cca9d11cb8..ae1a206917 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs @@ -93,7 +93,7 @@ public async Task GetDisplayList(Guid applicationId) // If AI Scoring feature is disabled or user lacks permission, filter out AI assessments var aiScoringEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.Scoring"); - var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.Analysis.ViewScoringResult); + var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.Analysis.ViewScoringResult); assessmentList = assessmentList .Where(a => !a.IsAiAssessment || (aiScoringEnabled && canViewAI)) .OrderByDescending(a => a.IsAiAssessment) @@ -394,7 +394,7 @@ public async Task UpdateAssessmentScore(AssessmentScoresDto dto) /// /// Thrown when the specified assessment is not an AI assessment. /// - [Authorize(AIPermissions.Analysis.ViewScoringResult)] + [Authorize(AIPermissions.Analysis.ViewScoringResult)] public async Task CloneFromAiAsync(Guid aiAssessmentId) { if (!await _featureChecker.IsEnabledAsync("Unity.AI.Scoring")) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs index 3a8a4bbffd..0458eee55e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs @@ -8,6 +8,7 @@ using Volo.Abp; using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Assessments; public class AssessmentAuthorizationHandler : AuthorizationHandler, ISingletonDependency @@ -81,7 +82,8 @@ protected virtual async Task CheckPolicyAsync(string permissionName, Autho { Check.NotNull(principal, nameof(principal)); - var userIdOrNull = principal.Claims?.FirstOrDefault(c => c.Type == "UserId"); + var userIdOrNull = principal.Claims?.FirstOrDefault(c => c.Type == AbpClaimTypes.UserId) + ?? principal.Claims?.FirstOrDefault(c => c.Type == "UserId"); if (userIdOrNull == null || userIdOrNull.Value.IsNullOrWhiteSpace()) { return null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Dashboard/DashboardAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Dashboard/DashboardAppService.cs index 9ae0271156..1fbda16cdd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Dashboard/DashboardAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Dashboard/DashboardAppService.cs @@ -205,16 +205,21 @@ public virtual async Task> GetRequestApprovedCo var applicationTags = await GetFilteredApplicationTags(applicationQuery, parameters); var filteredApplications = applicationQuery.Where(app => applicationTags.Contains(app.Id)); - var requestedAmount = await filteredApplications.SumAsync(app => app.RequestedAmount); - var approvedAmount = await filteredApplications.SumAsync(app => app.ApprovedAmount); + // Single query for both aggregates instead of two separate round-trips + var amounts = await filteredApplications + .GroupBy(_ => true) + .Select(g => new + { + RequestedAmount = g.Sum(a => a.RequestedAmount), + ApprovedAmount = g.Sum(a => a.ApprovedAmount) + }) + .FirstOrDefaultAsync(); - var queryResult = new List + return new List { - new GetRequestedApprovedAmtDto { Description = "Requested Amount", Amount = requestedAmount }, - new GetRequestedApprovedAmtDto { Description = "Approved Amount", Amount = approvedAmount } + new GetRequestedApprovedAmtDto { Description = "Requested Amount", Amount = amounts?.RequestedAmount ?? 0 }, + new GetRequestedApprovedAmtDto { Description = "Approved Amount", Amount = amounts?.ApprovedAmount ?? 0 } }; - - return queryResult; }); return requestApprovedAmtDto; @@ -222,17 +227,25 @@ public virtual async Task> GetRequestApprovedCo private async Task> GetFilteredApplicationTags(IQueryable applications, DashboardParameters parameters) { - var tags = await _applicationTagsRepository.WithDetailsAsync(); - var tagsResult = tags.Join(applications, tag => tag.ApplicationId, app => app.Id, (tag, app) => tag); - - var applicationIdsWithTags = tagsResult.AsEnumerable() - .SelectMany(tag => tag.Tag.Name.Split(','), (tagResult, tag) => new { tagResult.ApplicationId, Tag = tag }) + var tagQueryable = (await _applicationTagsRepository.GetQueryableAsync()).Include(tag => tag.Tag); + var applicationIds = applications.Select(a => a.Id).Distinct(); + var tagsResult = tagQueryable.Where(tag => applicationIds.Contains(tag.ApplicationId)); + + // Use async materialization and project to minimal fields to reduce memory pressure + var materializedTags = await tagsResult + .Select(tag => new { tag.ApplicationId, TagName = tag.Tag.Name }) + .Distinct() + .ToListAsync(); + + var applicationIdsWithTags = materializedTags + .SelectMany(tag => tag.TagName.Split(',', StringSplitOptions.TrimEntries), (tagResult, tagName) => new { tagResult.ApplicationId, Tag = tagName }) .Where(tag => parameters.Tags.Contains(tag.Tag)) .Select(tag => tag.ApplicationId) .ToHashSet(); if (parameters.Tags.Contains(string.Empty)) { + // Keep tagsResult as IQueryable so EF generates a SQL NOT EXISTS subquery var applicationsWithoutTags = applications.Where(app => !tagsResult.Any(res => res.ApplicationId == app.Id)); applications = applications.Where(app => applicationIdsWithTags.Contains(app.Id)).Union(applicationsWithoutTags); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs index efac6a4120..beed79955e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Cooldown; using Unity.AI.RateLimit; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index dbcdc17c33..8a5e0c853d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -3,13 +3,13 @@ using System.Linq; using System.Threading.Tasks; using Unity.AI.Domain; -using Unity.AI.Automation; +using Unity.AI.Generation; using Unity.AI.Features; using Unity.AI.Localization; using Unity.AI.Operations; -using Unity.AI.RateLimit; -using Unity.GrantManager.GrantApplications; +using Unity.AI.Cooldown; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; +using Unity.GrantManager.GrantApplications; using Medallion.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Localization; @@ -20,36 +20,37 @@ using Volo.Abp.Features; using Volo.Abp.Linq; using Volo.Abp.Users; +using Unity.GrantManager.Applications; namespace Unity.GrantManager.GrantApplications.Automation; -public class ApplicationAIGenerationQueue( +public class ApplicationGenerationQueue( IBackgroundJobManager backgroundJobManager, IRepository generationRequestRepository, IRepository operationRepository, IDistributedLockProvider distributedLockProvider, IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, IFeatureChecker featureChecker, - IAIRateLimiter aiRateLimiter, + IAICooldownService aiCooldownService, IAsyncQueryableExecuter asyncQueryableExecuter, ICurrentUser currentUser, - ILogger logger, - IStringLocalizer localizer) - : IApplicationAIGenerationQueue, ITransientDependency + ILogger logger) + : IApplicationGenerationQueue, ITransientDependency { private readonly IAsyncQueryableExecuter _asyncQueryableExecuter = asyncQueryableExecuter; - public async Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null) + public async Task QueueApplicationAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, List attachmentIds, string? promptVersion = null) { await EnsureRequestAndEnqueueAsync( tenantId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateAttachmentSummaryBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, AttachmentIds = attachmentIds, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, @@ -65,11 +66,12 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateApplicationAnalysisBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, TenantId = tenantId @@ -84,11 +86,54 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.ApplicationScoringOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateApplicationScoringBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, + PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, + TenantId = tenantId + }); + }); + } + + public async Task QueueFormMappingAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null) + { + await EnsureRequestAndEnqueueAsync( + tenantId, + AIGenerationRequestKeyHelper.FormMappingOperationType, + applicationId, + () => aiGenerationPrerequisiteValidator.EnsureFormMappingAvailableAsync(applicationFormVersionId), + operationId => + { + return backgroundJobManager.EnqueueAsync(new GenerateFormMappingBackgroundJobArgs + { + ApplicationId = applicationId, + OperationId = operationId, + ApplicationFormVersionId = applicationFormVersionId, + PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, + TenantId = tenantId + }); + }); + } + + public async Task QueueFormWorksheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null) + { + await EnsureRequestAndEnqueueAsync( + tenantId, + AIGenerationRequestKeyHelper.FormWorksheetOperationType, + applicationId, + () => aiGenerationPrerequisiteValidator.EnsureFormWorksheetAvailableAsync(applicationFormVersionId), + operationId => + { + return backgroundJobManager.EnqueueAsync(new GenerateFormWorksheetBackgroundJobArgs + { + ApplicationId = applicationId, + OperationId = operationId, + ApplicationFormVersionId = applicationFormVersionId, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, TenantId = tenantId @@ -96,7 +141,7 @@ await EnsureRequestAndEnqueueAsync( }); } - public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null) + public async Task QueueApplicationIntakeAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null) { var hasEnabledStage = false; var enqueuedStage = false; @@ -107,7 +152,7 @@ public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, stri hasEnabledStage = true; try { - await QueueAttachmentSummaryAsync(applicationId, tenantId, promptVersion); + await QueueApplicationAttachmentSummaryAsync(applicationId, tenantId, new List(), promptVersion: promptVersion); enqueuedStage = true; } catch (UserFriendlyException ex) @@ -146,7 +191,7 @@ public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, stri if (!hasEnabledStage) { - throw new UserFriendlyException(localizer[AILocalizationKeys.GenerateAllDisabled]); + throw new UserFriendlyException("No AI generation features are enabled."); } if (!enqueuedStage && lastStageException != null) @@ -160,7 +205,7 @@ private async Task EnsureRequestAndEnqueueAsync( string operationType, Guid applicationId, Func validateInput, - Func enqueue) + Func enqueue) { var operation = await ResolveOperationAsync(operationType); var requestLock = distributedLockProvider.CreateLock($"ai-generation:{tenantId}:{applicationId}:{operation.Id}"); @@ -188,7 +233,7 @@ private async Task EnsureRequestAndEnqueueAsync( // Single chokepoint for all AI generate flows (manual + auto). // The limiter is a no-op for system/background callers without an authenticated user. - await aiRateLimiter.EnsureAsync(); + await aiCooldownService.EnsureAsync(currentUser.Id); var request = new AIGenerationRequest( Guid.NewGuid(), @@ -200,7 +245,7 @@ private async Task EnsureRequestAndEnqueueAsync( try { - await enqueue(); + await enqueue(operation.Id); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs index 7a28bd3c2c..5ab904e4c0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Unity.AI.Domain; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Unity.GrantManager.GrantApplications; using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; @@ -56,18 +56,16 @@ public static async Task MarkFailedAsync( public static async Task MarkRunningInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, - IRepository operationRepository, Guid? tenantId, Guid applicationId, - string operationType) + Guid operationId) { using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var operation = await ResolveOperationAsync(operationRepository, operationType); var request = await GetLatestRequestAsync( generationRequestRepository, x => x.TenantId == tenantId && x.ApplicationId == applicationId - && x.OperationId == operation.Id); + && x.OperationId == operationId); await MarkRunningAsync(generationRequestRepository, request); await uow.CompleteAsync(); } @@ -75,18 +73,16 @@ public static async Task MarkRunningInNewUowAsync( public static async Task MarkCompletedInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, - IRepository operationRepository, Guid? tenantId, Guid applicationId, - string operationType) + Guid operationId) { using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var operation = await ResolveOperationAsync(operationRepository, operationType); var request = await GetLatestRequestAsync( generationRequestRepository, x => x.TenantId == tenantId && x.ApplicationId == applicationId - && x.OperationId == operation.Id); + && x.OperationId == operationId); await MarkCompletedAsync(generationRequestRepository, request); await uow.CompleteAsync(); } @@ -94,25 +90,23 @@ public static async Task MarkCompletedInNewUowAsync( public static async Task MarkFailedInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, - IRepository operationRepository, Guid? tenantId, Guid applicationId, - string operationType, + Guid operationId, string? failureReason) { using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var operation = await ResolveOperationAsync(operationRepository, operationType); var request = await GetLatestRequestAsync( generationRequestRepository, x => x.TenantId == tenantId && x.ApplicationId == applicationId - && x.OperationId == operation.Id); + && x.OperationId == operationId); await MarkFailedAsync(generationRequestRepository, request, failureReason); await uow.CompleteAsync(); } - public static async Task StampRateLimitBestEffortAsync( - IAIRateLimiter aiRateLimiter, + public static async Task StampCooldownBestEffortAsync( + IAICooldownService aiCooldownService, ILogger logger, Guid? requestedByUserId, Guid applicationId, @@ -120,33 +114,18 @@ public static async Task StampRateLimitBestEffortAsync( { try { - await aiRateLimiter.StampAsync(requestedByUserId); + await aiCooldownService.StampAsync(requestedByUserId); } catch (Exception ex) { logger.LogWarning( ex, - "AI rate-limit cooldown stamp failed after completed AI generation request for application {ApplicationId} and operation {OperationType}.", + "AI cooldown stamp failed after completed AI generation request for application {ApplicationId} and operation {OperationType}.", applicationId, operationType); } } - private static async Task ResolveOperationAsync( - IRepository operationRepository, - string operationType) - { - var operationName = AIGenerationRequestKeyHelper.ResolveOperationName(operationType); - if (operationName == null) - { - throw new ArgumentException($"Unknown AI operation type '{operationType}'.", nameof(operationType)); - } - - var operation = await operationRepository.FirstOrDefaultAsync(item => item.Name == operationName); - - return operation ?? throw new InvalidOperationException($"AI operation '{operationType}' is not configured."); - } - public static async Task GetLatestRequestAsync( IRepository generationRequestRepository, Expression> predicate) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index fac08879b1..7ece6972bb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -2,29 +2,28 @@ using System; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Cooldown; using Unity.AI.Operations; -using Unity.AI.RateLimit; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; +using Volo.Abp.ObjectMapping; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; -using Volo.Abp.ObjectMapping; namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationAnalysisJob( - IAIApplicationInputBuilder inputBuilder, - IApplicationAnalysisService applicationAnalysisService, + ApplicationAnalysisService applicationAnalysisService, + IAIApplicationInputBuilder aiApplicationInputBuilder, IApplicationRepository applicationRepository, - IObjectMapper objectMapper, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAIRateLimiter aiRateLimiter, + IAICooldownService aiCooldownService, + IObjectMapper objectMapper, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args) @@ -42,36 +41,33 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); + args.OperationId); try { var application = await applicationRepository.GetAsync(args.ApplicationId); - var applicationInput = objectMapper.Map(application); - var input = await inputBuilder.BuildApplicationAnalysisInputAsync(applicationInput, args.PromptVersion); - var analysisJson = await applicationAnalysisService.RegenerateAsync(input); + var promptData = objectMapper.Map(application); + var analysisInput = await aiApplicationInputBuilder.BuildApplicationAnalysisInputAsync(promptData, args.PromptVersion); + var analysisJson = await applicationAnalysisService.RegenerateAsync(analysisInput); application.AIAnalysis = analysisJson; await applicationRepository.UpdateAsync(application); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index d171503d61..949e7f4615 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -2,31 +2,30 @@ using System; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Cooldown; using Unity.AI.Operations; -using Unity.AI.RateLimit; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications.Automation.Events; +using Volo.Abp.ObjectMapping; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus.Local; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; -using Volo.Abp.ObjectMapping; namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationScoringJob( - IAIApplicationInputBuilder inputBuilder, - IApplicationScoringService applicationScoringService, + ApplicationScoringService applicationScoringService, + IAIApplicationInputBuilder aiApplicationInputBuilder, IApplicationRepository applicationRepository, - IObjectMapper objectMapper, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, ILocalEventBus localEventBus, - IAIRateLimiter aiRateLimiter, + IAICooldownService aiCooldownService, + IObjectMapper objectMapper, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args) @@ -44,40 +43,37 @@ public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobA await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationScoringOperationType); + args.OperationId); try { var application = await applicationRepository.GetAsync(args.ApplicationId); - var applicationInput = objectMapper.Map(application); - var input = await inputBuilder.BuildApplicationScoringInputAsync(applicationInput, args.PromptVersion); - var scoresheetAnswers = await applicationScoringService.RegenerateAsync(input); + var promptData = objectMapper.Map(application); + var scoringInput = await aiApplicationInputBuilder.BuildApplicationScoringInputAsync(promptData, args.PromptVersion); + var scoresheetAnswers = await applicationScoringService.RegenerateAsync(scoringInput); application.AIScoresheetAnswers = scoresheetAnswers; await applicationRepository.UpdateAsync(application); await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent { ApplicationId = args.ApplicationId }); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationScoringOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationScoringOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs index 2ebab73bef..5b7716fc01 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs @@ -2,8 +2,8 @@ using System; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Cooldown; using Unity.AI.Operations; -using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; @@ -16,10 +16,9 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateAttachmentSummaryJob( IAttachmentSummaryService attachmentSummaryService, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAIRateLimiter aiRateLimiter, + IAICooldownService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args) @@ -37,32 +36,33 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType); + args.OperationId); try { - await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion, args.AttachmentIds); + await attachmentSummaryService.GenerateForApplicationAsync( + args.ApplicationId, + args.PromptVersion, + args.AttachmentIds, + default); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs new file mode 100644 index 0000000000..5508ad9eb1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Unity.AI.Domain; +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 Volo.Abp.Domain.Repositories; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormMappingJob( + IApplicationFormVersionMappingReadService mappingReadService, + IFormMappingService aiService, + IRepository applicationFormVersionRepository, + IRepository generationRequestRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + IAICooldownService aiCooldownService, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateFormMappingBackgroundJobArgs args) + { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.FormMappingOperationType, + args.ApplicationId, + args.TenantId, + args.PromptVersion, + args.RequestedByUserId); + + using (currentTenant.Change(args.TenantId)) + { + await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId); + try + { + var readModel = await mappingReadService.GetAsync(args.ApplicationFormVersionId); + var response = await aiService.GenerateFormMappingAsync(new FormMappingRequest + { + Data = FormMappingPromptDataBuilder.Build(readModel), + PromptVersion = args.PromptVersion + }); + + var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); + var applicationFormVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); + applicationFormVersion.SubmissionHeaderMapping = submissionHeaderMapping; + await applicationFormVersionRepository.UpdateAsync(applicationFormVersion, true); + + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormMappingOperationType); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId); + } + catch (System.Exception ex) + { + await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId, + ex.Message); + throw; + } + } + } + +} 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 new file mode 100644 index 0000000000..50b105898a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs @@ -0,0 +1,266 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.AI.Cooldown; +using Unity.AI.Operations; +using Unity.AI.Requests; +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 Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormWorksheetJob( + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationFormRepository applicationFormRepository, + IWorksheetRepository worksheetRepository, + IWorksheetLinkRepository worksheetLinkRepository, + IApplicationFormVersionMappingReadService mappingReadService, + IFormWorksheetService aiService, + IRepository generationRequestRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + IAICooldownService aiCooldownService, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public override async Task ExecuteAsync(GenerateFormWorksheetBackgroundJobArgs args) + { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.FormWorksheetOperationType, + args.ApplicationId, + args.TenantId, + args.PromptVersion, + args.RequestedByUserId); + + using (currentTenant.Change(args.TenantId)) + { + await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId); + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); + var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); + var worksheetName = BuildWorksheetName(formVersion.Id, applicationForm.Id); + var existingWorksheet = await worksheetRepository.GetByNameAsync(worksheetName, true); + var mappingReadModel = await mappingReadService.GetAsync(formVersion.Id); + + List worksheetSnapshots = []; + if (existingWorksheet != null) + { + worksheetSnapshots.Add(existingWorksheet); + } + var promptData = 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, + existingWorksheets = worksheetSnapshots.Select(worksheet => 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 + { + field.Name, + field.Key, + field.Label, + field.Type, + field.Order, + field.Enabled, + field.Definition + }) + }) + }) + }; + + 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) + { + 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.MarkCompletedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId); + } + catch (Exception ex) + { + await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId, + ex.Message); + throw; + } + } + } + + internal static CreateWorksheetDto 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 }) + { + 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) + { + ReportColumns = dto.ReportColumns, + ReportKeys = dto.ReportKeys, + ReportViewName = dto.ReportViewName + }; + + worksheet.SetVersion(dto.Version); + worksheet.SetPublished(dto.Published); + + 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); + + 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); + } + } + + return worksheet; + } + + private static Worksheet RebuildWorksheet(Worksheet worksheet, CreateWorksheetDto dto) + { + worksheet.SetName(worksheet.Name); + worksheet.SetTitle(dto.Title); + worksheet.SetVersion(dto.Version); + worksheet.SetPublished(dto.Published); + worksheet.SetReportingFields(dto.ReportKeys, dto.ReportColumns, dto.ReportViewName); + + 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); + + 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); + } + } + + return worksheet; + } + + private async Task UpsertWorksheetLinkAsync(Guid worksheetId, Guid correlationId) + { + var existingLink = await worksheetLinkRepository.GetExistingLinkAsync(worksheetId, correlationId, CorrelationConsts.FormVersion); + if (existingLink != null) + { + existingLink.SetAnchor(FlexConsts.CustomTab).SetOrder(1); + await worksheetLinkRepository.UpdateAsync(existingLink); + return; + } + + await worksheetLinkRepository.InsertAsync(new WorksheetLink( + Guid.NewGuid(), + worksheetId, + correlationId, + CorrelationConsts.FormVersion, + FlexConsts.CustomTab, + 1)); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs index d80d0b4b88..2459de85c8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; -using Unity.AI.Automation; +using Unity.AI.Generation; using Unity.AI.Settings; using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes.Events; @@ -13,7 +13,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.Handlers; public class QueueApplicationAIPipelineOnProcessHandler( - IApplicationAIGenerationQueue aiGenerationQueue, + IApplicationGenerationQueue aiGenerationQueue, ISettingProvider settingProvider, IApplicationFormRepository applicationFormRepository, IFeatureChecker featureChecker, @@ -53,7 +53,7 @@ public async Task HandleEventAsync(ApplicationProcessEvent eventData) try { - await aiGenerationQueue.QueueAllAIStagesAsync(eventData.Application.Id, eventData.Application.TenantId); + await aiGenerationQueue.QueueApplicationIntakeAsync(eventData.Application.Id, eventData.Application.TenantId); logger.LogInformation("Queued AI pipeline for application {ApplicationId}.", eventData.Application.Id); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 602de5054d..e82413d38b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -8,26 +8,25 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Unity.AI.Automation; -using Unity.AI.Models; -using Unity.AI.Permissions; -using Unity.AI.RateLimit; -using Unity.AI.Responses; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Models; +using Unity.AI.Permissions; +using Unity.AI.Responses; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; using Unity.GrantManager.Applicants; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; -using Unity.GrantManager.Events; -using Unity.GrantManager.Flex; -using Unity.GrantManager.GlobalTag; -using Unity.GrantManager.Identity; -using Unity.GrantManager.Payments; -using Unity.Modules.Shared; +using Unity.GrantManager.Events; +using Unity.GrantManager.Flex; +using Unity.GrantManager.GlobalTag; +using Unity.GrantManager.Identity; +using Unity.GrantManager.Payments; +using Unity.GrantManager.GrantApplications.Automation; +using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; using Unity.Modules.Shared.Specializations; using Unity.Payments.PaymentRequests; @@ -53,13 +52,12 @@ public class GrantApplicationAppService( IApplicationStatusRepository applicationStatusRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicantRepository applicantRepository, - IApplicationFormRepository applicationFormRepository, - IApplicantAgentRepository applicantAgentRepository, - IApplicantAddressRepository applicantAddressRepository, - IApplicantSupplierAppService applicantSupplierService, + IApplicationFormRepository applicationFormRepository, + IApplicantAgentRepository applicantAgentRepository, + IApplicantAddressRepository applicantAddressRepository, + IApplicantSupplierAppService applicantSupplierService, IPaymentRequestAppService paymentRequestService, IAIGenerationStatusAppService aiGenerationStatusAppService, - IAIRateLimiter aiRateLimiter, IFeatureChecker featureChecker) : GrantManagerAppService, IGrantApplicationAppService #pragma warning restore S107 // Methods should not have too many parameters @@ -1234,7 +1232,7 @@ private async Task EnsureAttachmentSummariesEnabledAsync() } } - private async Task> ResolveAttachmentSummaryIdsAsync(QueueAttachmentSummaryRequestDto input) + private async Task> ResolveAttachmentSummaryIdsAsync(QueueAttachmentSummaryRequestDto input) { if (input == null) { @@ -1368,13 +1366,10 @@ public async Task GetAIGenerationStatusAsync(Guid applica await EnsureAIGenerationStatusAccessAsync(operationType); var request = await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id); - var state = await aiRateLimiter.GetStateAsync(); return new AIGenerationStatusDto { GenerationRequest = request, - IsGenerating = state.IsGenerating, - RetryAfterSeconds = state.RetryAfterSeconds, FailureReason = request?.FailureReason }; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs index b0935db0f0..e5177775a9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Threading; +using System.Linq; using System.Threading.Tasks; using Volo.Abp; -using Volo.Abp.Auditing; using Volo.Abp.AuditLogging; using Volo.Abp.Data; using Volo.Abp.Domain.ChangeTracking; @@ -16,72 +15,92 @@ public class HistoryAppService( IIdentityUserRepository identityUserRepository, IDataFilter softDataFilter) : GrantManagerAppService, IHistoryAppService { + /// + /// Gets the list of entity property changes based on the provided input parameters. + /// + /// The ID of the entity for which to retrieve property changes. + /// The name of the property to filter changes by. + /// An optional dictionary for looking up display values for property changes. + /// A list of history DTOs representing the entity property changes. [DisableEntityChangeTracking] - public async Task> GetHistoryList(string? entityId, - string filterPropertyName, - Dictionary? lookupDictionary) + public virtual async Task> GetHistoryList( + string? entityId, + string filterPropertyName, + Dictionary? lookupDictionary) { - List historyList = []; - string? sorting = null; - int maxResultCount = 50; - int skipCount = 0; - DateTime? startTime = null; - DateTime? endTime = null; - bool includeDetails = true; - Guid? auditLogId = null; - EntityChangeType? changeType = null; - string? entityTypeFullName = null; - CancellationToken cancellationToken = default; + return await GetEntityPropertyChangesAsync( + new GetEntityPropertyChangesInput + { + EntityId = entityId, + PropertyNames = [filterPropertyName], + }, + lookupDictionary); + } + /// + /// Gets the list of entity property changes for a specific entity. + /// + /// The input parameters for retrieving entity property changes. + /// An optional dictionary for looking up display values for property changes. + /// A list of history DTOs representing the entity property changes. + [DisableEntityChangeTracking] + public virtual async Task> GetEntityPropertyChangesAsync( + GetEntityPropertyChangesInput input, + Dictionary? lookupDictionary = null) + { var entityChanges = await auditLogRepository.GetEntityChangeListAsync( - sorting, - maxResultCount, - skipCount, - auditLogId, - startTime, endTime, - changeType, - entityId, - entityTypeFullName, - includeDetails, - cancellationToken); + sorting: null, + maxResultCount: input.MaxResultCount, + skipCount: input.SkipCount, + auditLogId: null, + startTime: input.StartTime, + endTime: input.EndTime, + changeType: null, + entityId: input.EntityId, + entityTypeFullName: input.EntityTypeFullName, + includeDetails: true, + cancellationToken: default); + + var propertyNames = (input.PropertyNames ?? []).ToHashSet(StringComparer.Ordinal); + var historyList = new List(); + var userNameCache = new Dictionary(); foreach (var entityChange in entityChanges) { foreach (var propertyChange in entityChange.PropertyChanges) { - if (propertyChange.PropertyName == filterPropertyName) + if (propertyNames.Count > 0 && !propertyNames.Contains(propertyChange.PropertyName)) { - string origninalValue = CleanValue(propertyChange.OriginalValue); - string newValue = CleanValue(propertyChange.NewValue); - // Signal the kind of time so that tolocal knows how to convert it on the page - DateTime utcDateTime = DateTime.SpecifyKind(entityChange.ChangeTime, DateTimeKind.Utc); - HistoryDto historyDto = new() - { - OriginalValue = GetLookupValue(origninalValue, lookupDictionary), - NewValue = GetLookupValue(newValue, lookupDictionary), - ChangeTime = utcDateTime, - UserName = await LookupUserName(entityChange.AuditLogId) - }; - historyList.Add(historyDto); + continue; } + + string originalValue = CleanValue(propertyChange.OriginalValue); + string newValue = CleanValue(propertyChange.NewValue); + DateTime utcDateTime = DateTime.SpecifyKind(entityChange.ChangeTime, DateTimeKind.Utc); + + if (!userNameCache.TryGetValue(entityChange.AuditLogId, out var userName)) + { + userName = await LookupUserNameAsync(entityChange.AuditLogId); + userNameCache[entityChange.AuditLogId] = userName; + } + + historyList.Add(new HistoryDto + { + PropertyName = propertyChange.PropertyName, + PropertyTypeFullName = propertyChange.PropertyTypeFullName ?? string.Empty, + OriginalValue = GetLookupValue(originalValue, lookupDictionary), + NewValue = GetLookupValue(newValue, lookupDictionary), + ChangeTime = utcDateTime, + ChangeType = (int)entityChange.ChangeType, + UserName = userName, + }); } } - return historyList; - } - - private static string CleanValue(string? value) - { - return value?.Replace("\"", "") ?? ""; - } - private static string GetLookupValue(string value, Dictionary? lookupDictionary) - { - return lookupDictionary != null && lookupDictionary.TryGetValue(value, out var lookupValue) - ? lookupValue - : value; + return historyList; } - public async Task LookupUserName(Guid auditLogId) + protected virtual async Task LookupUserNameAsync(Guid auditLogId) { var auditLog = await auditLogRepository.GetAsync(auditLogId); if (auditLog?.UserId == null || auditLog.UserId == Guid.Empty) @@ -96,4 +115,21 @@ public async Task LookupUserName(Guid auditLogId) return user != null ? $"{user.Name} {user.Surname}" : "(Full-Deleted User)"; } } + + public async Task LookupUserName(Guid auditLogId) + { + return await LookupUserNameAsync(auditLogId); + } + + private static string CleanValue(string? value) + { + return value?.Replace("\"", "") ?? ""; + } + + private static string GetLookupValue(string value, Dictionary? lookupDictionary) + { + return lookupDictionary != null && lookupDictionary.TryGetValue(value, out var lookupValue) + ? lookupValue + : value; + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UnityPermissionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UnityPermissionAppService.cs index 4eff3d210f..48e2774535 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UnityPermissionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UnityPermissionAppService.cs @@ -1,9 +1,11 @@ +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; using Volo.Abp.PermissionManagement; using Volo.Abp.SimpleStateChecking; +using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Identity; @@ -21,10 +23,14 @@ public class UnityPermissionAppService( { protected override Task HasAdminRoleAsync() { + // AbpClaimTypes.Role (not UnityClaimsTypes.Role/"client_roles") - it's dynamically + // recomputed by ABP from the DB on every request, so it reflects the user's current + // DB-assigned roles even though the cookie no longer stamps them at login. + var roles = CurrentUser.FindClaims(AbpClaimTypes.Role).Select(c => c.Value); return Task.FromResult( - CurrentUser.IsInRole("admin") || - CurrentUser.IsInRole(UnityRoles.SystemAdmin) || - CurrentUser.IsInRole(UnityRoles.ProgramManager) + roles.Contains("admin") || + roles.Contains(UnityRoles.SystemAdmin) || + roles.Contains(UnityRoles.ProgramManager) ); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index 59f7fa3a8c..f581bd38ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -63,6 +63,29 @@ private async Task RemoveFromKeySetAsync(string cacheKey, Guid? tenantId) }); } } + + [UnitOfWork] + [RemoteService(false)] + [AllowAnonymous] + public async Task GetGitHubRepoUrlAsync() + { + var url = await GetUrlByKeyNameInternalAsync(DynamicUrlKeyNames.GITHUB_REPO, tenantSpecific: false); + if (string.IsNullOrWhiteSpace(url)) + throw new UserFriendlyException("GitHub repo URL not configured."); + return url!; + } + + [UnitOfWork] + [RemoteService(false)] + [AllowAnonymous] + public async Task GetGitHubGraphQlUrlAsync() + { + var url = await GetUrlByKeyNameInternalAsync(DynamicUrlKeyNames.GITHUB_GRAPHQL, tenantSpecific: false); + if (string.IsNullOrWhiteSpace(url)) + throw new UserFriendlyException("GitHub GraphQL URL not configured."); + return url!; + } + [UnitOfWork] [RemoteService(false)] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Logs/ExceptionLogAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Logs/ExceptionLogAppService.cs new file mode 100644 index 0000000000..1efd04b43d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Logs/ExceptionLogAppService.cs @@ -0,0 +1,318 @@ +using System; +using System.Linq; +using System.Linq.Dynamic.Core; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using Unity.Modules.Shared.Constants; +using Unity.Modules.Shared.Permissions; +using Unity.Notifications.EmailNotifications; + +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; +using Volo.Abp.Data; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Logs; + +public class ExceptionLogAppService( + IRepository exceptionLogRepository, + IDataFilter dataFilter, + IEmailNotificationService emailNotificationService) + : ApplicationService, IExceptionLogAppService +{ + private const string AlertFromAddress = "NoReply@gov.bc.ca"; + private const int AlertEmailSuppressionWindowDays = 5; + + // Called from background jobs and exception middleware where there may be no + // current tenant/user, so this intentionally does not check CurrentTenant. + [RemoteService(false)] + public virtual async Task CreateAsync(CreateExceptionLogDto input) + { + // Exceptions can originate from host-side/background contexts with no current tenant, + // so the duplicate lookup must not be restricted to the caller's ambient tenant. + using (dataFilter.Disable()) + { + var existing = await FindTodaysDuplicateAsync(input); + if (existing != null) + { + ApplyOccurrence(existing, input); + existing.OccurrenceCount++; + await exceptionLogRepository.UpdateAsync(existing, autoSave: true); + return existing.Id; + } + + // Check before inserting today's row, otherwise the row we're about to create + // would satisfy its own "has this happened recently" check. + var seenRecently = await HasOccurredWithinAsync(input, AlertEmailSuppressionWindowDays); + + var exceptionLog = new ExceptionLog + { + TenantId = CurrentTenant.Id + }; + ApplyOccurrence(exceptionLog, input); + + exceptionLog = await exceptionLogRepository.InsertAsync(exceptionLog, autoSave: true); + + // Only email when this error hasn't been seen in the last N days — a recurring + // error still gets a fresh per-day row (for OccurrenceCount) but stays quiet by email. + if (!seenRecently) + { + await TrySendAlertEmailAsync(exceptionLog); + } + + return exceptionLog.Id; + } + } + + private async Task HasOccurredWithinAsync(CreateExceptionLogDto input, int days) + { + var since = DateTime.UtcNow.AddDays(-days); + + var queryable = await exceptionLogRepository.GetQueryableAsync(); + + return await AsyncExecuter.AnyAsync( + queryable + .Where(x => x.CreationTime >= since) + .Where(x => x.Source == input.Source) + .Where(x => x.Environment == input.Environment) + .Where(x => x.ExceptionType == input.ExceptionType) + .Where(x => x.ExceptionMessage == input.ExceptionMessage)); + } + + private async Task TrySendAlertEmailAsync(ExceptionLog log) + { + try + { + var subject = $"Unity Exception Alert: {log.ExceptionType ?? log.Title}"; + var htmlBody = BuildAlertEmailBody(log); + + await emailNotificationService.SendEmailNotification( + new EmailMessageParams( + UnityAlertConstants.UnityAlertEmail, + htmlBody, + subject, + AlertFromAddress, + ""), + "html"); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to send exception alert email for {ExceptionType}", log.ExceptionType); + } + } + + private static string BuildAlertEmailBody(ExceptionLog log) + { + var rows = new StringBuilder(); + + AppendRow(rows, "Exception", log.ExceptionType); + AppendRow(rows, "Message", log.ExceptionMessage ?? log.Message); + AppendRow(rows, "Severity", log.Severity.ToString()); + AppendRow(rows, "Environment", log.Environment); + AppendRow(rows, "Source", log.Source); + AppendRow(rows, "Endpoint", log.SourceReference); + AppendRow(rows, "Author", log.BlameAuthor); + AppendRow(rows, "Source Line", log.SourceFile == null ? null : $"{log.SourceFile}:{log.SourceLine}"); + AppendRow(rows, "Line of Code", GetTopStackLine(log.StackExcerpt)); + AppendRow(rows, "User", log.UserName); + AppendRow(rows, "Tenant", log.TenantName); + AppendRow(rows, "Ticket", log.TicketReference); + AppendRow(rows, "Correlation Id", log.CorrelationId); + + return $@" + +

    {WebUtility.HtmlEncode(log.Title)}

    + +{rows} +
    +

    *Note - Please do not reply to this email as it is an automated notification.

    + +"; + } + + // Every row is a single short label/value line — keeps the alert scannable at a glance. + private static void AppendRow(StringBuilder rows, string label, string? value, int maxLength = 200) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + var trimmed = value.Length > maxLength ? value[..maxLength] + "…" : value; + + rows.Append(" ") + .Append(WebUtility.HtmlEncode(label)) + .Append("") + .Append(WebUtility.HtmlEncode(trimmed)) + .Append("") + .AppendLine(); + } + + // The first application frame in the stack excerpt reads as "ClassName.MethodName() in file:line" — + // the closest thing to "the line of code" responsible, short of fetching the file from source control. + private static string? GetTopStackLine(string? stackExcerpt) + { + if (string.IsNullOrWhiteSpace(stackExcerpt)) + { + return null; + } + + return stackExcerpt + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() + ?.Trim(); + } + + // Refreshes every field captured for the current occurrence, so a recurring same-day error + // always reflects the latest user/tenant, blame lookup, and source location — even if an + // earlier occurrence of the same error was logged before that information was available. + private static void ApplyOccurrence(ExceptionLog exceptionLog, CreateExceptionLogDto input) + { + exceptionLog.UserId = input.UserId; + exceptionLog.UserName = input.UserName; + exceptionLog.TenantName = input.TenantName; + exceptionLog.NotificationType = input.NotificationType; + exceptionLog.Channel = input.Channel; + exceptionLog.Severity = input.Severity; + exceptionLog.Title = input.Title; + exceptionLog.Message = input.Message; + exceptionLog.Source = input.Source; + exceptionLog.SourceReference = input.SourceReference; + exceptionLog.PayloadJson = input.PayloadJson; + exceptionLog.CorrelationId = input.CorrelationId; + exceptionLog.IsDeliveredRealtime = input.IsDeliveredRealtime; + exceptionLog.DeliveryTarget = input.DeliveryTarget; + exceptionLog.ExceptionType = input.ExceptionType; + exceptionLog.ExceptionMessage = input.ExceptionMessage; + exceptionLog.StackExcerpt = input.StackExcerpt; + exceptionLog.SourceFile = input.SourceFile; + exceptionLog.SourceLine = input.SourceLine; + exceptionLog.CommitSha = input.CommitSha; + exceptionLog.Environment = input.Environment; + exceptionLog.BlameAuthor = input.BlameAuthor; + exceptionLog.BlameEmail = input.BlameEmail; + exceptionLog.BlameCommitSha = input.BlameCommitSha; + exceptionLog.BlameCommitMessage = input.BlameCommitMessage; + exceptionLog.PullRequestUrl = input.PullRequestUrl; + exceptionLog.PullRequestNumber = input.PullRequestNumber; + exceptionLog.PullRequestTitle = input.PullRequestTitle; + exceptionLog.TicketReference = input.TicketReference; + } + + // Same error (type + message + source + environment) recurring on the same UTC calendar day + // is only persisted once; later occurrences bump OccurrenceCount instead of adding new rows. + // A match on SourceFile + SourceLine is also treated as a duplicate on its own, since the + // same line throwing is the same error even if the message text varies slightly. + private async Task FindTodaysDuplicateAsync(CreateExceptionLogDto input) + { + var todayStart = DateTime.UtcNow.Date; + var todayEnd = todayStart.AddDays(1); + + var queryable = await exceptionLogRepository.GetQueryableAsync(); + + return await AsyncExecuter.FirstOrDefaultAsync( + queryable + .Where(x => x.CreationTime >= todayStart && x.CreationTime < todayEnd) + .Where(x => + (x.Source == input.Source && + x.Environment == input.Environment && + x.ExceptionType == input.ExceptionType && + x.ExceptionMessage == input.ExceptionMessage) || + (input.SourceFile != null && input.SourceLine != null && + x.SourceFile == input.SourceFile && x.SourceLine == input.SourceLine))); + } + + [Authorize(IdentityConsts.ITOperationsPolicyName)] + public virtual async Task> GetListAsync(GetExceptionLogsInput input) + { + // IT operations need visibility across all tenants, including host-side/background + // exceptions that have no TenantId — the default multi-tenancy filter would otherwise + // hide those rows whenever this is called from within a tenant context. + using (dataFilter.Disable()) + { + var query = await exceptionLogRepository.GetQueryableAsync(); + + if (input.FromDate.HasValue) + { + query = query.Where(x => x.CreationTime >= input.FromDate.Value); + } + + if (input.ToDate.HasValue) + { + query = query.Where(x => x.CreationTime <= input.ToDate.Value); + } + + if (input.Severity.HasValue) + { + query = query.Where(x => x.Severity == input.Severity.Value); + } + + if (!string.IsNullOrWhiteSpace(input.Filter)) + { + var filter = input.Filter.Trim(); + query = query.Where(x => + x.Title.Contains(filter) || + x.Message.Contains(filter) || + (x.Source != null && x.Source.Contains(filter)) || + (x.ExceptionType != null && x.ExceptionType.Contains(filter)) || + (x.CorrelationId != null && x.CorrelationId.Contains(filter)) || + (x.TicketReference != null && x.TicketReference.Contains(filter)) || + (x.BlameAuthor != null && x.BlameAuthor.Contains(filter)) || + (x.UserName != null && x.UserName.Contains(filter)) || + (x.TenantName != null && x.TenantName.Contains(filter))); + } + + var totalCount = await AsyncExecuter.CountAsync(query); + + query = query + .OrderBy(string.IsNullOrWhiteSpace(input.Sorting) ? "CreationTime DESC" : input.Sorting) + .Skip(input.SkipCount) + .Take(input.MaxResultCount); + + var logs = await AsyncExecuter.ToListAsync(query); + + var items = logs.Select(MapToDto).ToList(); + + return new PagedResultDto(totalCount, items); + } + } + + private static ExceptionLogDto MapToDto(ExceptionLog log) => new() + { + Id = log.Id, + CreationTime = log.CreationTime, + TenantId = log.TenantId, + UserId = log.UserId, + UserName = log.UserName, + TenantName = log.TenantName, + NotificationType = log.NotificationType, + Channel = log.Channel, + Severity = log.Severity, + Title = log.Title, + Message = log.Message, + Source = log.Source, + SourceReference = log.SourceReference, + OccurrenceCount = log.OccurrenceCount, + CorrelationId = log.CorrelationId, + ExceptionType = log.ExceptionType, + ExceptionMessage = log.ExceptionMessage, + StackExcerpt = log.StackExcerpt, + SourceFile = log.SourceFile, + SourceLine = log.SourceLine, + CommitSha = log.CommitSha, + Environment = log.Environment, + BlameAuthor = log.BlameAuthor, + BlameEmail = log.BlameEmail, + BlameCommitSha = log.BlameCommitSha, + BlameCommitMessage = log.BlameCommitMessage, + PullRequestUrl = log.PullRequestUrl, + PullRequestNumber = log.PullRequestNumber, + PullRequestTitle = log.PullRequestTitle, + TicketReference = log.TicketReference + }; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs deleted file mode 100644 index 8e50a4188e..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Unity.GrantManager.Applications; -using Unity.GrantManager.Integrations; -using Unity.GrantManager.Notifications.Teams; -using Unity.Notifications.Teams; -using Volo.Abp; -using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; -using Volo.Abp.Uow; - -namespace Unity.GrantManager.Notifications -{ - // This class is responsible for first lookup up the Teams channel URL from the database and then posting notifications to the Teams Service. - [Dependency(ReplaceServices = true)] - [ExposeServices(typeof(NotificationsAppService), typeof(INotificationsAppService))] - public class NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository) : INotificationsAppService, ITransientDependency - { - private readonly IDynamicUrlRepository _dynamicUrlRepository = dynamicUrlRepository; - private readonly TeamsNotificationService _teamsNotificationService = new(); - - [UnitOfWork] - public async Task InitializeTeamsChannelAsync(string keyName) - { - DynamicUrl? teamsChannel = await _dynamicUrlRepository.FirstOrDefaultAsync(q => q.KeyName == keyName); - if (teamsChannel?.Url == null) - { - return ""; - } - return teamsChannel.Url; - } - - [RemoteService(false)] - public async Task NotifyChefsEventToTeamsAsync(string factName, string factValue, bool alert = false) - { - string teamsChannel = await InitializeTeamsChannelAsync(alert ? TeamsNotificationService.TEAMS_ALERT : TeamsNotificationService.TEAMS_NOTIFICATION); - if (teamsChannel.IsNullOrEmpty()) - { - return; - } - - string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - string activityTitle = "Chefs Submission Event Validation Error"; - string activitySubtitle = "Environment: " + envInfo; - _teamsNotificationService.AddFact(factName, factValue); - await _teamsNotificationService.PostFactsToTeamsAsync(teamsChannel, activityTitle, activitySubtitle); - } - - [UnitOfWork] - public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle, List facts) - { - string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); - if (teamsChannel.IsNullOrEmpty()) - { - return; - } - - string messageCard = TeamsNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); - await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); - } - - public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle) - { - string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); - if (teamsChannel.IsNullOrEmpty()) - { - return; - } - List facts = []; - string messageCard = TeamsNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); - await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); - } - - public async Task PostChefsEventToTeamsAsync(string subscriptionEvent, dynamic form, dynamic chefsFormVersion) - { - string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); - if (teamsChannel.IsNullOrEmpty()) - { - return; - } - await TeamsNotificationService.PostChefsEventToTeamsAsync(teamsChannel, subscriptionEvent, form, chefsFormVersion); - } - } -} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/EmailAppService.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/EmailAppService.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationListAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/NotificationListAppService.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationListAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/NotificationListAppService.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/NotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/NotificationsAppService.cs new file mode 100644 index 0000000000..a72914b3a3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/NotificationsAppService.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Integrations; +using Unity.GrantManager.Notifications.Logs; +using Unity.Notifications.Teams; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.Notifications +{ + // This class is responsible for first lookup up the Teams channel URL from the database and then posting notifications to the Teams Service. + [Dependency(ReplaceServices = true)] + [ExposeServices(typeof(NotificationsAppService), typeof(INotificationsAppService))] + public class NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository, + ILogger logger, ICurrentTenant currentTenant) : INotificationsAppService, ITransientDependency + { + + [UnitOfWork] + public async Task InitializeTeamsChannelAsync(string keyName) + { + using (currentTenant.Change(null)) + { + DynamicUrl? teamsChannel = await dynamicUrlRepository.FirstOrDefaultAsync(q => q.KeyName == keyName && q.TenantId == null); + if (teamsChannel?.Url == null) + { + logger.LogWarning("Teams channel not found for key {KeyName}", keyName); + return string.Empty; + } + + return teamsChannel.Url; + } + } + + [RemoteService(false)] + public async Task NotifyChefsEventToTeamsAsync(string factName, string factValue, bool alert = false) + { + string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + string activityTitle = "Chefs Submission Event Validation Error"; + string activitySubtitle = "Environment: " + envInfo; + LogNotificationService LogNotificationService = new(); + LogNotificationService.AddFact(factName, factValue); + await LogNotificationService.LogFactsToNotificationsAsync(NotificationType.UnityAlert, activityTitle, activitySubtitle); + } + + [UnitOfWork] + public async Task PostToNotificationsAsync(string activityTitle, string activitySubtitle, List facts) + { + string teamsChannel = await InitializeTeamsChannelAsync(LogNotificationService.TEAMS_NOTIFICATION); + if (teamsChannel.IsNullOrEmpty()) + { + logger.LogWarning("PostToNotificationsAsync: no Teams channel configured, skipping notification LogNotificationService.TEAMS_NOTIFICATION"); + return; + } + + string messageCard = LogNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); + try + { + await LogNotificationService.PostToNotificationsChannelAsync(NotificationType.UnityAlert, messageCard); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to post Teams notification to channel"); + } + } + + public async Task PostToNotificationsAsync(string activityTitle, string activitySubtitle) + { + string teamsChannel = await InitializeTeamsChannelAsync(LogNotificationService.TEAMS_NOTIFICATION); + if (teamsChannel.IsNullOrEmpty()) + { + logger.LogWarning("PostToNotificationsAsync (no-facts): no Teams channel configured, skipping notification"); + return; + } + List facts = []; + string messageCard = LogNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); + try + { + await LogNotificationService.PostToNotificationsChannelAsync(NotificationType.UnityAlert, messageCard); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to post Teams notification to channel"); + } + } + + public async Task PostChefsEventToTeamsAsync(string subscriptionEvent, dynamic form, dynamic chefsFormVersion) + { + await LogNotificationService.PostChefsEventToNotificationsAsync(NotificationType.ChefsEvent, subscriptionEvent, form, chefsFormVersion); + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs index 02b9f2b665..ba28bcd185 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Unity.GrantManager.Applications; using Unity.GrantManager.Notifications; -using Unity.GrantManager.Notifications.Teams; +using Unity.GrantManager.Notifications.Logs; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -79,7 +79,7 @@ private async Task NotifyTeamsAsync(SubmissionsDynamicViewGenerationArgs viewGen using (currentTenant.Change(viewGenerationEvent.TenantId)) { using var notifyUow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - await notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + await notificationsAppService.PostToNotificationsAsync(activityTitle, activitySubtitle, facts); await notifyUow.CompleteAsync(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json index 1e36d565d5..325101ebd6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json @@ -2,7 +2,7 @@ "ConnectionStrings": { "Default": "Host=localhost;port=5432;Database=UnityGrantManager;Username=postgres;", "Tenant": "Host=localhost;port=5432;Database=UnityGrantTenant;Username=postgres;", - "Onboarding": "Host=localhost;port=5432;Database=UnityOnboarding;Username=postgres;" + "Onboarding": "Host=localhost;port=5432;Database=Onboarding;Username=postgres;" }, "StringEncryption": { "DefaultPassPhrase": "g2IuZx7PwXDvCmlW" @@ -31,4 +31,4 @@ "Settings": { "Abp.Localization.DefaultLanguage": "en-CA" } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs index d478b3af4f..706a0bbbca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs @@ -8,6 +8,8 @@ public static class AIGenerationRequestKeyHelper public const string ApplicationAnalysisOperationType = "application-analysis"; public const string ApplicationScoringOperationType = "application-scoring"; public const string PipelineOperationType = "pipeline"; + public const string FormMappingOperationType = "form-mapping"; + public const string FormWorksheetOperationType = "form-worksheet"; public static string BuildRequestKey(Guid? tenantId, Guid applicationId, string operationType) { @@ -31,6 +33,8 @@ public static string BuildRequestKey(Guid? tenantId, Guid applicationId, string ApplicationAnalysisOperationType => "ApplicationAnalysis", AttachmentSummaryOperationType => "AttachmentSummary", ApplicationScoringOperationType => "ApplicationScoring", + FormMappingOperationType => "FormMapping", + FormWorksheetOperationType => "FormWorksheet", PipelineOperationType => "Default", _ => null }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs index 64379739bc..a0d3efd332 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs @@ -13,6 +13,8 @@ public static class DynamicUrlKeyNames public const string DIRECT_MESSAGE_KEY_PREFIX = "DIRECT_MESSAGE_"; // Teams Direct Message URL Weebhook- Dynamically incremented public const string WEBHOOK_KEY_PREFIX = "WEBHOOK_"; // General Webhook URL - Dynamically incremented public const string GEOCODER_API_BASE = "GEOCODER_API_BASE"; + public const string GITHUB_REPO = "GITHUB_REPO"; + public const string GITHUB_GRAPHQL = "GITHUB_GRAPHQL"; public const string GEOCODER_LOCATION_API_BASE = "GEOCODER_LOCATION_API_BASE"; public const string ANALYTICS_MATOMO_BASE = "ANALYTICS_MATOMO_BASE"; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogChannel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogChannel.cs new file mode 100644 index 0000000000..b40f8f4613 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogChannel.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.Logs; + +public enum ExceptionLogChannel +{ + ExceptionPipeline = 0, + Prometheus = 1, + BackgroundJob = 2, + System = 3 +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogSeverity.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogSeverity.cs new file mode 100644 index 0000000000..595f911d86 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogSeverity.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.Logs; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExceptionLogSeverity +{ + Info = 0, + Warning = 1, + Error = 2, + Critical = 3 +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogType.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogType.cs new file mode 100644 index 0000000000..1e565e3769 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Logs/ExceptionLogType.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.Logs; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExceptionLogType +{ + AbpHandledException = 0, + MiddlewareUnhandledException = 1, + PrometheusErrorCounterEvent = 2, + PrometheusExceptionCounterEvent = 3, + BackgroundJobException = 4, + DbException = 5, + UnhandledException = 6 +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs index d75788f2ca..7ec5d02662 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs @@ -44,8 +44,9 @@ public async Task SeedAsync(DataSeedContext context) if (context.TenantId == null) // only seed into a tenant database { await SeedMainBackgroundJobUserAsync(null); + await SeedUnityAlertUserAsync(null); return; - } + } await SeedApplicationStatusAsync(); await SeedAiScoringPersonAsync(context.TenantId); @@ -134,4 +135,27 @@ await userRepository.InsertAsync( } } } + + private async Task SeedUnityAlertUserAsync(Guid? tenantId) + { + using (currentTenant.Change(tenantId)) // Null For Main Unity Grant Manager Context + { + // Check if the IdentityUser already exists + var existingUser = await userRepository.FindAsync(UnityAlertConstants.UnityAlertPersonId); + if (existingUser == null) + { + // Create the IdentityUser in the tenant context + await userRepository.InsertAsync( + new IdentityUser( + UnityAlertConstants.UnityAlertPersonId, + UnityAlertConstants.UnityAlertUserName, + UnityAlertConstants.UnityAlertEmail, + null) + { + Name = UnityAlertConstants.UnityAlertName + }, + autoSave: true); + } + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs index f14f85f12a..d383550eb0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs @@ -35,6 +35,8 @@ public static class DynamicUrls public const string MATOMO_DEV_URL = $"{PROTOCOL}//dev-analytics-matomo.apps.silver.devops.gov.bc.ca"; public const string MATOMO_TEST_URL = $"{PROTOCOL}//test-analytics-matomo.apps.silver.devops.gov.bc.ca"; public const string MATOMO_PROD_URL = $"{PROTOCOL}//prod-analytics-matomo.apps.silver.devops.gov.bc.ca"; + public const string GITHUB_REPO = $"{PROTOCOL}//github.com/bcgov/Unity"; + public const string GITHUB_GRAPHQL = $"{PROTOCOL}//api.github.com/graphql"; } private static string GetMatomoUrl() @@ -67,12 +69,15 @@ private async Task SeedDynamicUrlAsync() new() { KeyName = DynamicUrlKeyNames.REPORTING_AI, Url = DynamicUrls.REPORTING_AI, Description = "Reporting AI iFrame Source" }, new() { KeyName = DynamicUrlKeyNames.NOTIFICATION_AUTH, Url = DynamicUrls.CHES_PROD_AUTH, Description = "Common Hosted Email Service OAUTH" }, new() { KeyName = DynamicUrlKeyNames.ANALYTICS_MATOMO_BASE, Url = GetMatomoUrl(), Description = "Matomo Analytics" }, + new() { KeyName = DynamicUrlKeyNames.GITHUB_REPO, Url = DynamicUrls.GITHUB_REPO, Description = "GitHub Repository" }, + new() { KeyName = DynamicUrlKeyNames.GITHUB_GRAPHQL, Url = DynamicUrls.GITHUB_GRAPHQL, Description = "GitHub GraphQL Endpoint" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.WEBHOOK_KEY_PREFIX}{webhookIndex++}", Url = "", Description = $"Webhook {webhookIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.WEBHOOK_KEY_PREFIX}{webhookIndex++}", Url = "", Description = $"Webhook {webhookIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.WEBHOOK_KEY_PREFIX}{webhookIndex++}", Url = "", Description = $"Webhook {webhookIndex}" }, + }; foreach (var dynamicUrl in dynamicUrls) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Logs/ExceptionLog.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Logs/ExceptionLog.cs new file mode 100644 index 0000000000..0f7a6830fc --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Logs/ExceptionLog.cs @@ -0,0 +1,42 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Logs; + +public class ExceptionLog : AuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public string? UserName { get; set; } + public string? TenantName { get; set; } + public ExceptionLogType NotificationType { get; set; } + public ExceptionLogChannel Channel { get; set; } + public ExceptionLogSeverity Severity { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? SourceReference { get; set; } + public string? PayloadJson { get; set; } + public string? CorrelationId { get; set; } + public int OccurrenceCount { get; set; } = 1; + public bool IsDeliveredRealtime { get; set; } + public string? DeliveryTarget { get; set; } + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public string? StackExcerpt { get; set; } + public string? SourceFile { get; set; } + public int? SourceLine { get; set; } + public string? CommitSha { get; set; } + public string? Environment { get; set; } + + // Git blame enrichment: identifies who wrote the failing line and which PR/ticket shipped it. + public string? BlameAuthor { get; set; } + public string? BlameEmail { get; set; } + public string? BlameCommitSha { get; set; } + public string? BlameCommitMessage { get; set; } + public string? PullRequestUrl { get; set; } + public int? PullRequestNumber { get; set; } + public string? PullRequestTitle { get; set; } + public string? TicketReference { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index 2525fee37b..03e898b420 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -1,358 +1,358 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Unity.AI.Permissions; -using Unity.Flex.Permissions; -using Unity.GrantManager.Identity; -using Unity.Modules.Shared; -using Unity.Notifications.Permissions; -using Unity.Payments.Permissions; -using Volo.Abp.Authorization.Permissions; -using Volo.Abp.Data; -using Volo.Abp.DependencyInjection; -using Volo.Abp.PermissionManagement; - -namespace Unity.GrantManager.Permissions -{ - internal class PermissionGrantsDataSeeder : IDataSeedContributor, ITransientDependency - { - private readonly IPermissionDataSeeder _permissionDataSeeder; - - public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) - { - _permissionDataSeeder = permissionDataSeeder; - } - - public readonly List ReviewAndAssessment_CommonPermissions = [ - UnitySelector.Review.Default, - UnitySelector.Review.Approval.Default, - UnitySelector.Review.Approval.Update.Default, - - UnitySelector.Review.AssessmentResults.Default, - UnitySelector.Review.AssessmentResults.Update.Default, - - UnitySelector.Review.AssessmentReviewList.Default, - UnitySelector.Review.AssessmentReviewList.Create, - UnitySelector.Review.AssessmentReviewList.Update.SendBack, - UnitySelector.Review.AssessmentReviewList.Update.Complete - ]; - - public readonly List ApplicantInfo_CommonPermissions = [ - UnitySelector.Applicant.Default, - UnitySelector.Applicant.Summary.Default, - UnitySelector.Applicant.Summary.Update, - UnitySelector.Applicant.Contact.Default, - UnitySelector.Applicant.Contact.Update, - UnitySelector.Applicant.Authority.Default, - UnitySelector.Applicant.Authority.Update, - UnitySelector.Applicant.Location.Default, - UnitySelector.Applicant.Location.Update, - UnitySelector.Applicant.AdditionalContact.Default, - UnitySelector.Applicant.AdditionalContact.Create, - UnitySelector.Applicant.AdditionalContact.Update, - - ]; - - public readonly List ProjectInfo_CommonPermissions = [ - UnitySelector.Project.Default, - UnitySelector.Project.Summary.Default, - UnitySelector.Project.Summary.Update.Default, - UnitySelector.Project.Location.Default, - UnitySelector.Project.Location.Update.Default, - ]; - - public readonly List PaymentInfo_CommonPermissions = [ - UnitySelector.Payment.Default, - UnitySelector.Payment.Summary.Default, - UnitySelector.Payment.Supplier.Default, - UnitySelector.Payment.PaymentList.Default - ]; - - public readonly List Notifications_CommonPermissions = [ - NotificationsPermissions.Email.Default, - NotificationsPermissions.Email.Send, - NotificationsPermissions.Email.DeleteDraft - ]; - - public readonly List NotificationsScheduling_CommonPermissions = [ - NotificationsPermissions.Email.CancelScheduled, - NotificationsPermissions.Email.ScheduleCreate, - NotificationsPermissions.Email.ScheduleCancel - ]; - - public readonly List Dashboard_CommonPermissions = [ - GrantApplicationPermissions.Dashboard.Default, - GrantApplicationPermissions.Dashboard.ViewDashboard, - GrantApplicationPermissions.Dashboard.ApplicationStatusCount, - GrantApplicationPermissions.Dashboard.EconomicRegionCount, - GrantApplicationPermissions.Dashboard.ApplicationTagsCount, - GrantApplicationPermissions.Dashboard.ApplicationAssigneeCount, - GrantApplicationPermissions.Dashboard.RequestedAmountPerSubsector, - GrantApplicationPermissions.Dashboard.RequestApprovedCount, - ]; - - public readonly List SettingManagement_Tags_CommonPermissions = [ - UnitySelector.SettingManagement.Tags.Default, - UnitySelector.SettingManagement.Tags.Create, - UnitySelector.SettingManagement.Tags.Update, - UnitySelector.SettingManagement.Tags.Delete - ]; - - public readonly List Tags_CommonPermissions = [ - UnitySelector.Application.Tags.Create, - UnitySelector.Application.Tags.Delete, - UnitySelector.Payment.Tags.Create, - UnitySelector.Payment.Tags.Delete, - ]; - - public readonly List ExternalStatusVisibility_CommonPermissions = [ - UnitySelector.Application.Status.Default, - UnitySelector.Application.Status.Publish, - UnitySelector.Application.Status.Unpublish, - UnitySelector.Application.Status.BulkPublish - ]; - - public async Task SeedAsync(DataSeedContext context) - { - // Default permission grants based on role - - // - Program Manager - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.ProgramManager, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - GrantApplicationPermissions.Assignments.AssignInitial, - GrantApplicationPermissions.Reviews.StartInitial, - GrantApplicationPermissions.Reviews.CompleteInitial, - GrantApplicationPermissions.Comments.Add, - GrantManagerPermissions.Organizations.Default, - GrantManagerPermissions.Organizations.ManageProfiles, - IdentitySeedPermissions.Users.Default, - IdentitySeedPermissions.Users.Create, - IdentitySeedPermissions.Users.Update, - IdentitySeedPermissions.Users.Delete, - IdentitySeedPermissions.Users.ManagePermissions, - IdentitySeedPermissions.Roles.Default, - IdentitySeedPermissions.Roles.Create, - IdentitySeedPermissions.Roles.Update, - IdentitySeedPermissions.Roles.Delete, - IdentitySeedPermissions.Roles.ManagePermissions, - GrantManagerPermissions.Intakes.Default, - GrantManagerPermissions.ApplicationForms.Default, - UnitySettingManagementPermissions.UserInterface, - UnitySettingManagementPermissions.EditProgramDetails, - - .. SettingManagement_Tags_CommonPermissions, - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - UnitySelector.Payment.Supplier.Update, - .. Notifications_CommonPermissions, - .. NotificationsScheduling_CommonPermissions, - .. Dashboard_CommonPermissions, - .. Tags_CommonPermissions, - .. ExternalStatusVisibility_CommonPermissions, - AIPermissions.Configuration.ConfigureAI, - FlexPermissions.Worksheets.Default, - FlexPermissions.Worksheets.Delete - ], context.TenantId); - - // - Reviewer - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.Reviewer, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - GrantApplicationPermissions.Reviews.StartInitial, - GrantApplicationPermissions.Reviews.CompleteInitial, - GrantApplicationPermissions.Comments.Add, - - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - .. Notifications_CommonPermissions, - .. Dashboard_CommonPermissions - ], context.TenantId); - - // - Assessor - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.Assessor, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - GrantApplicationPermissions.Reviews.StartInitial, - GrantApplicationPermissions.Reviews.CompleteInitial, - GrantApplicationPermissions.Comments.Add, - - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - .. Notifications_CommonPermissions, - .. Dashboard_CommonPermissions, - .. Tags_CommonPermissions - ], context.TenantId); - - // - TeamLead - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.TeamLead, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - GrantApplicationPermissions.Assignments.AssignInitial, - GrantApplicationPermissions.Applicants.AssignApplicant, - GrantApplicationPermissions.Reviews.StartInitial, - GrantApplicationPermissions.Reviews.CompleteInitial, - GrantApplicationPermissions.Comments.Add, - GrantManagerPermissions.Organizations.Default, - GrantManagerPermissions.Organizations.ManageProfiles, - GrantApplicationPermissions.Approvals.BulkApplicationApproval, - GrantApplicationPermissions.Approvals.DeferAfterApproval, - - .. SettingManagement_Tags_CommonPermissions, - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - UnitySelector.Payment.Supplier.Update, - .. Notifications_CommonPermissions, - .. NotificationsScheduling_CommonPermissions, - .. Dashboard_CommonPermissions, - .. Tags_CommonPermissions, - .. ExternalStatusVisibility_CommonPermissions, - - // Role Specific Permissions - UnitySelector.Project.Summary.Update.UpdateFinalStateFields, - UnitySelector.Project.Location.Update.UpdateFinalStateFields, - ], context.TenantId); - - // - Approver - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.Approver, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - GrantApplicationPermissions.Approvals.Complete, - GrantApplicationPermissions.Approvals.DeferAfterApproval, - GrantApplicationPermissions.Comments.Add, - - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - .. Notifications_CommonPermissions, - .. Dashboard_CommonPermissions - ], context.TenantId); - - // - SystemAdmin - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.SystemAdmin, - [ - GrantManagerPermissions.Default, - UnitySettingManagementPermissions.UserInterface, - UnitySettingManagementPermissions.EditProgramDetails, - GrantManagerPermissions.Organizations.Default, - GrantManagerPermissions.Organizations.ManageProfiles, - GrantManagerPermissions.Intakes.Default, - GrantManagerPermissions.ApplicationForms.Default, - - - .. SettingManagement_Tags_CommonPermissions, - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - UnitySelector.Payment.Supplier.Update, - .. Notifications_CommonPermissions, - .. NotificationsScheduling_CommonPermissions, - NotificationsPermissions.Settings, - .. Dashboard_CommonPermissions, - .. Tags_CommonPermissions, - UnitySettingManagementPermissions.ConfigurePayments, - UnitySettingManagementPermissions.BackgroundJobSettings, - AIPermissions.Configuration.ConfigureAI, - ], context.TenantId); - - - // -L1 Approver - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.L1Approver, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - PaymentsPermissions.Payments.Default, - PaymentsPermissions.Payments.L1ApproveOrDecline, - - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - .. Notifications_CommonPermissions, - .. Dashboard_CommonPermissions - ], context.TenantId); - - // -L2 Approver - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.L2Approver, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - PaymentsPermissions.Payments.Default, - PaymentsPermissions.Payments.L2ApproveOrDecline, - - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - .. Notifications_CommonPermissions, - .. Dashboard_CommonPermissions - ], context.TenantId); - - // -L3 Approver - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.L3Approver, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - PaymentsPermissions.Payments.Default, - PaymentsPermissions.Payments.L3ApproveOrDecline, - - .. ReviewAndAssessment_CommonPermissions, - .. ApplicantInfo_CommonPermissions, - .. ProjectInfo_CommonPermissions, - .. PaymentInfo_CommonPermissions, - .. Notifications_CommonPermissions, - .. Dashboard_CommonPermissions - ], context.TenantId); - - // -External Assessor - await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.ExternalAssessor, - [ - GrantManagerPermissions.Default, - GrantApplicationPermissions.Applications.Default, - PaymentsPermissions.Payments.Default, - - UnitySelector.Review.Default, - UnitySelector.Review.Approval.Default, - UnitySelector.Review.AssessmentResults.Default, - UnitySelector.Review.AssessmentReviewList.Default, - UnitySelector.Review.AssessmentReviewList.Create, - UnitySelector.Review.AssessmentReviewList.Update.SendBack, - UnitySelector.Review.AssessmentReviewList.Update.Complete, - UnitySelector.Review.Worksheet.Default, - - UnitySelector.Applicant.Default, - UnitySelector.Applicant.Summary.Default, - UnitySelector.Applicant.Contact.Default, - UnitySelector.Applicant.Authority.Default, - UnitySelector.Applicant.Location.Default, - UnitySelector.Applicant.AdditionalContact.Default, - - UnitySelector.Project.Default, - UnitySelector.Project.Summary.Default, - UnitySelector.Project.Location.Default, - - UnitySelector.Payment.Default, - UnitySelector.Payment.Summary.Default, - UnitySelector.Payment.Supplier.Default, - UnitySelector.Payment.PaymentList.Default, - - NotificationsPermissions.Email.Default - ], context.TenantId); - - } - } -} +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.AI.Permissions; +using Unity.Flex.Permissions; +using Unity.GrantManager.Identity; +using Unity.Modules.Shared; +using Unity.Notifications.Permissions; +using Unity.Payments.Permissions; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.PermissionManagement; + +namespace Unity.GrantManager.Permissions +{ + internal class PermissionGrantsDataSeeder : IDataSeedContributor, ITransientDependency + { + private readonly IPermissionDataSeeder _permissionDataSeeder; + + public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) + { + _permissionDataSeeder = permissionDataSeeder; + } + + public readonly List ReviewAndAssessment_CommonPermissions = [ + UnitySelector.Review.Default, + UnitySelector.Review.Approval.Default, + UnitySelector.Review.Approval.Update.Default, + + UnitySelector.Review.AssessmentResults.Default, + UnitySelector.Review.AssessmentResults.Update.Default, + + UnitySelector.Review.AssessmentReviewList.Default, + UnitySelector.Review.AssessmentReviewList.Create, + UnitySelector.Review.AssessmentReviewList.Update.SendBack, + UnitySelector.Review.AssessmentReviewList.Update.Complete + ]; + + public readonly List ApplicantInfo_CommonPermissions = [ + UnitySelector.Applicant.Default, + UnitySelector.Applicant.Summary.Default, + UnitySelector.Applicant.Summary.Update, + UnitySelector.Applicant.Contact.Default, + UnitySelector.Applicant.Contact.Update, + UnitySelector.Applicant.Authority.Default, + UnitySelector.Applicant.Authority.Update, + UnitySelector.Applicant.Location.Default, + UnitySelector.Applicant.Location.Update, + UnitySelector.Applicant.AdditionalContact.Default, + UnitySelector.Applicant.AdditionalContact.Create, + UnitySelector.Applicant.AdditionalContact.Update, + + ]; + + public readonly List ProjectInfo_CommonPermissions = [ + UnitySelector.Project.Default, + UnitySelector.Project.Summary.Default, + UnitySelector.Project.Summary.Update.Default, + UnitySelector.Project.Location.Default, + UnitySelector.Project.Location.Update.Default, + ]; + + public readonly List PaymentInfo_CommonPermissions = [ + UnitySelector.Payment.Default, + UnitySelector.Payment.Summary.Default, + UnitySelector.Payment.Supplier.Default, + UnitySelector.Payment.PaymentList.Default + ]; + + public readonly List Notifications_CommonPermissions = [ + NotificationsPermissions.Email.Default, + NotificationsPermissions.Email.Send, + NotificationsPermissions.Email.DeleteDraft + ]; + + public readonly List NotificationsScheduling_CommonPermissions = [ + NotificationsPermissions.Email.CancelScheduled, + NotificationsPermissions.Email.ScheduleCreate, + NotificationsPermissions.Email.ScheduleCancel + ]; + + public readonly List Dashboard_CommonPermissions = [ + GrantApplicationPermissions.Dashboard.Default, + GrantApplicationPermissions.Dashboard.ViewDashboard, + GrantApplicationPermissions.Dashboard.ApplicationStatusCount, + GrantApplicationPermissions.Dashboard.EconomicRegionCount, + GrantApplicationPermissions.Dashboard.ApplicationTagsCount, + GrantApplicationPermissions.Dashboard.ApplicationAssigneeCount, + GrantApplicationPermissions.Dashboard.RequestedAmountPerSubsector, + GrantApplicationPermissions.Dashboard.RequestApprovedCount, + ]; + + public readonly List SettingManagement_Tags_CommonPermissions = [ + UnitySelector.SettingManagement.Tags.Default, + UnitySelector.SettingManagement.Tags.Create, + UnitySelector.SettingManagement.Tags.Update, + UnitySelector.SettingManagement.Tags.Delete + ]; + + public readonly List Tags_CommonPermissions = [ + UnitySelector.Application.Tags.Create, + UnitySelector.Application.Tags.Delete, + UnitySelector.Payment.Tags.Create, + UnitySelector.Payment.Tags.Delete, + ]; + + public readonly List ExternalStatusVisibility_CommonPermissions = [ + UnitySelector.Application.Status.Default, + UnitySelector.Application.Status.Publish, + UnitySelector.Application.Status.Unpublish, + UnitySelector.Application.Status.BulkPublish + ]; + + public async Task SeedAsync(DataSeedContext context) + { + // Default permission grants based on role + + // - Program Manager + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.ProgramManager, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + GrantApplicationPermissions.Assignments.AssignInitial, + GrantApplicationPermissions.Reviews.StartInitial, + GrantApplicationPermissions.Reviews.CompleteInitial, + GrantApplicationPermissions.Comments.Add, + GrantManagerPermissions.Organizations.Default, + GrantManagerPermissions.Organizations.ManageProfiles, + IdentitySeedPermissions.Users.Default, + IdentitySeedPermissions.Users.Create, + IdentitySeedPermissions.Users.Update, + IdentitySeedPermissions.Users.Delete, + IdentitySeedPermissions.Users.ManagePermissions, + IdentitySeedPermissions.Roles.Default, + IdentitySeedPermissions.Roles.Create, + IdentitySeedPermissions.Roles.Update, + IdentitySeedPermissions.Roles.Delete, + IdentitySeedPermissions.Roles.ManagePermissions, + GrantManagerPermissions.Intakes.Default, + GrantManagerPermissions.ApplicationForms.Default, + UnitySettingManagementPermissions.UserInterface, + UnitySettingManagementPermissions.EditProgramDetails, + + .. SettingManagement_Tags_CommonPermissions, + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + UnitySelector.Payment.Supplier.Update, + .. Notifications_CommonPermissions, + .. NotificationsScheduling_CommonPermissions, + .. Dashboard_CommonPermissions, + .. Tags_CommonPermissions, + .. ExternalStatusVisibility_CommonPermissions, + AIPermissions.Configuration.ConfigureAI, + FlexPermissions.Worksheets.Default, + FlexPermissions.Worksheets.Delete + ], context.TenantId); + + // - Reviewer + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.Reviewer, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + GrantApplicationPermissions.Reviews.StartInitial, + GrantApplicationPermissions.Reviews.CompleteInitial, + GrantApplicationPermissions.Comments.Add, + + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + .. Notifications_CommonPermissions, + .. Dashboard_CommonPermissions + ], context.TenantId); + + // - Assessor + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.Assessor, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + GrantApplicationPermissions.Reviews.StartInitial, + GrantApplicationPermissions.Reviews.CompleteInitial, + GrantApplicationPermissions.Comments.Add, + + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + .. Notifications_CommonPermissions, + .. Dashboard_CommonPermissions, + .. Tags_CommonPermissions + ], context.TenantId); + + // - TeamLead + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.TeamLead, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + GrantApplicationPermissions.Assignments.AssignInitial, + GrantApplicationPermissions.Applicants.AssignApplicant, + GrantApplicationPermissions.Reviews.StartInitial, + GrantApplicationPermissions.Reviews.CompleteInitial, + GrantApplicationPermissions.Comments.Add, + GrantManagerPermissions.Organizations.Default, + GrantManagerPermissions.Organizations.ManageProfiles, + GrantApplicationPermissions.Approvals.BulkApplicationApproval, + GrantApplicationPermissions.Approvals.DeferAfterApproval, + + .. SettingManagement_Tags_CommonPermissions, + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + UnitySelector.Payment.Supplier.Update, + .. Notifications_CommonPermissions, + .. NotificationsScheduling_CommonPermissions, + .. Dashboard_CommonPermissions, + .. Tags_CommonPermissions, + .. ExternalStatusVisibility_CommonPermissions, + + // Role Specific Permissions + UnitySelector.Project.Summary.Update.UpdateFinalStateFields, + UnitySelector.Project.Location.Update.UpdateFinalStateFields, + ], context.TenantId); + + // - Approver + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.Approver, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + GrantApplicationPermissions.Approvals.Complete, + GrantApplicationPermissions.Approvals.DeferAfterApproval, + GrantApplicationPermissions.Comments.Add, + + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + .. Notifications_CommonPermissions, + .. Dashboard_CommonPermissions + ], context.TenantId); + + // - SystemAdmin + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.SystemAdmin, + [ + GrantManagerPermissions.Default, + UnitySettingManagementPermissions.UserInterface, + UnitySettingManagementPermissions.EditProgramDetails, + GrantManagerPermissions.Organizations.Default, + GrantManagerPermissions.Organizations.ManageProfiles, + GrantManagerPermissions.Intakes.Default, + GrantManagerPermissions.ApplicationForms.Default, + + + .. SettingManagement_Tags_CommonPermissions, + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + UnitySelector.Payment.Supplier.Update, + .. Notifications_CommonPermissions, + .. NotificationsScheduling_CommonPermissions, + NotificationsPermissions.Settings, + .. Dashboard_CommonPermissions, + .. Tags_CommonPermissions, + UnitySettingManagementPermissions.ConfigurePayments, + UnitySettingManagementPermissions.BackgroundJobSettings, + AIPermissions.Configuration.ConfigureAI, + ], context.TenantId); + + + // -L1 Approver + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.L1Approver, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + PaymentsPermissions.Payments.Default, + PaymentsPermissions.Payments.L1ApproveOrDecline, + + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + .. Notifications_CommonPermissions, + .. Dashboard_CommonPermissions + ], context.TenantId); + + // -L2 Approver + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.L2Approver, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + PaymentsPermissions.Payments.Default, + PaymentsPermissions.Payments.L2ApproveOrDecline, + + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + .. Notifications_CommonPermissions, + .. Dashboard_CommonPermissions + ], context.TenantId); + + // -L3 Approver + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.L3Approver, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + PaymentsPermissions.Payments.Default, + PaymentsPermissions.Payments.L3ApproveOrDecline, + + .. ReviewAndAssessment_CommonPermissions, + .. ApplicantInfo_CommonPermissions, + .. ProjectInfo_CommonPermissions, + .. PaymentInfo_CommonPermissions, + .. Notifications_CommonPermissions, + .. Dashboard_CommonPermissions + ], context.TenantId); + + // -External Assessor + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnityRoles.ExternalAssessor, + [ + GrantManagerPermissions.Default, + GrantApplicationPermissions.Applications.Default, + PaymentsPermissions.Payments.Default, + + UnitySelector.Review.Default, + UnitySelector.Review.Approval.Default, + UnitySelector.Review.AssessmentResults.Default, + UnitySelector.Review.AssessmentReviewList.Default, + UnitySelector.Review.AssessmentReviewList.Create, + UnitySelector.Review.AssessmentReviewList.Update.SendBack, + UnitySelector.Review.AssessmentReviewList.Update.Complete, + UnitySelector.Review.Worksheet.Default, + + UnitySelector.Applicant.Default, + UnitySelector.Applicant.Summary.Default, + UnitySelector.Applicant.Contact.Default, + UnitySelector.Applicant.Authority.Default, + UnitySelector.Applicant.Location.Default, + UnitySelector.Applicant.AdditionalContact.Default, + + UnitySelector.Project.Default, + UnitySelector.Project.Summary.Default, + UnitySelector.Project.Location.Default, + + UnitySelector.Payment.Default, + UnitySelector.Payment.Summary.Default, + UnitySelector.Payment.Supplier.Default, + UnitySelector.Payment.PaymentList.Default, + + NotificationsPermissions.Email.Default + ], context.TenantId); + + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 9351b3d8e4..52484024dd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -5,6 +5,7 @@ using Unity.GrantManager.Applicants; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Locality; +using Unity.GrantManager.Logs; using Unity.GrantManager.Tokens; using Volo.Abp.AuditLogging.EntityFrameworkCore; using Volo.Abp.BackgroundJobs.EntityFrameworkCore; @@ -50,6 +51,7 @@ public class GrantManagerDbContext : public DbSet InboxMessages { get; set; } public DbSet OutboxMessages { get; set; } public DbSet AIGenerationRequests { get; set; } + public DbSet ExceptionLogs { get; set; } // Unity.AI entities public DbSet AIPrompts { get; set; } @@ -239,20 +241,57 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(x => new { x.Source, x.Status }); }); - modelBuilder.Entity(b => - { - b.ToTable(GrantManagerConsts.DbTablePrefix + "AIRequests", AIDbProperties.DbSchema); - b.ConfigureByConvention(); - b.Property(x => x.ApplicationId).IsRequired(); - b.Property(x => x.OperationId).IsRequired(); - b.Property(x => x.FailureReason).HasMaxLength(2000); - b.Property(x => x.Status).IsRequired(); - b.HasOne() - .WithMany() + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "AIRequests", AIDbProperties.DbSchema); + b.ConfigureByConvention(); + b.Property(x => x.ApplicationId).IsRequired(); + b.Property(x => x.OperationId).IsRequired(); + b.Property(x => x.FailureReason).HasMaxLength(2000); + b.Property(x => x.Status).IsRequired(); + b.HasOne() + .WithMany() .HasForeignKey(x => x.OperationId) .OnDelete(DeleteBehavior.Restrict); - b.HasIndex(x => x.OperationId); - b.HasIndex(x => new { x.TenantId, x.ApplicationId, x.OperationId, x.Status }); + b.HasIndex(x => x.OperationId); + b.HasIndex(x => new { x.TenantId, x.ApplicationId, x.OperationId, x.Status }); + }); + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "ExceptionLogs", GrantManagerConsts.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.NotificationType).HasConversion().HasMaxLength(64); + b.Property(x => x.Channel).HasConversion().HasMaxLength(32); + b.Property(x => x.Severity).HasConversion().HasMaxLength(32); + b.Property(x => x.Title).IsRequired().HasMaxLength(256); + b.Property(x => x.Message).IsRequired(); + b.Property(x => x.Source).IsRequired().HasMaxLength(200); + b.Property(x => x.SourceReference).HasMaxLength(256); + b.Property(x => x.OccurrenceCount).HasDefaultValue(1); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DeliveryTarget).HasMaxLength(256); + b.Property(x => x.ExceptionType).HasMaxLength(256); + b.Property(x => x.Environment).HasMaxLength(64); + b.Property(x => x.UserName).HasMaxLength(256); + b.Property(x => x.TenantName).HasMaxLength(256); + b.Property(x => x.SourceFile).HasMaxLength(512); + b.Property(x => x.CommitSha).HasMaxLength(64); + b.Property(x => x.PayloadJson).HasColumnType("jsonb"); + b.Property(x => x.BlameAuthor).HasMaxLength(256); + b.Property(x => x.BlameEmail).HasMaxLength(256); + b.Property(x => x.BlameCommitSha).HasMaxLength(64); + b.Property(x => x.BlameCommitMessage).HasMaxLength(512); + b.Property(x => x.PullRequestUrl).HasMaxLength(512); + b.Property(x => x.PullRequestTitle).HasMaxLength(512); + b.Property(x => x.TicketReference).HasMaxLength(64); + + b.HasIndex(x => new { x.TenantId, x.CreationTime }); + b.HasIndex(x => new { x.NotificationType, x.CreationTime }); + b.HasIndex(x => x.CorrelationId); + b.HasIndex(x => x.TicketReference); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260720172009_AddExceptionLogs.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260720172009_AddExceptionLogs.Designer.cs new file mode 100644 index 0000000000..8e62df594c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260720172009_AddExceptionLogs.Designer.cs @@ -0,0 +1,5571 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + [DbContext(typeof(GrantManagerDbContext))] + [Migration("20260720172009_AddExceptionLogs")] + partial class AddExceptionLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("ReportsComments") + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.HasIndex("OrgName"); + + b.HasIndex("OrgNumber"); + + b.HasIndex("Status"); + + b.HasIndex("SupplierId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UnityApplicantId"); + + b.HasIndex("TenantId", "IsDeleted", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ApplicantAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DefaultSiteId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExternalStatusVisibility") + .HasColumnType("boolean"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AutomaticallyGenerateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ManuallyInitiateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.HasIndex("TenantId", "IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("NotifiedStatus") + .HasColumnType("text"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditStatus") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("AuditorName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("text"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OneTimeConsideration") + .HasColumnType("numeric"); + + b.Property("PaidDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalYear") + .HasColumnType("text"); + + b.Property("IncompleteReport") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Outstanding") + .HasColumnType("boolean"); + + b.Property("ReportDate") + .HasColumnType("timestamp without time zone"); + + b.Property("SignedOff") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ReportsHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicantComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.HasIndex("TenantId"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Logs.ExceptionLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlameAuthor") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BlameCommitMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("BlameCommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("BlameEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeliveryTarget") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Environment") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExceptionMessage") + .HasColumnType("text"); + + b.Property("ExceptionType") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeliveredRealtime") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestTitle") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PullRequestUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StackExcerpt") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TicketReference") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TicketReference"); + + b.HasIndex("NotificationType", "CreationTime"); + + b.HasIndex("TenantId", "CreationTime"); + + b.ToTable("ExceptionLogs", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Logs.ExceptionLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlameAuthor") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BlameCommitMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("BlameCommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("BlameEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeliveryTarget") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Environment") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExceptionMessage") + .HasColumnType("text"); + + b.Property("ExceptionType") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeliveredRealtime") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestTitle") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PullRequestUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StackExcerpt") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TicketReference") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TicketReference"); + + b.HasIndex("NotificationType", "CreationTime"); + + b.HasIndex("TenantId", "CreationTime"); + + b.ToTable("ExceptionLogs", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationStatus") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DateField") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EmailTemplateId") + .HasColumnType("uuid"); + + b.Property("EventType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerDetail") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ScheduledNotifications", "Notifications"); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DateField") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("ScheduledNotificationId"); + + b.HasIndex("ApplicationId", "ScheduledNotificationId", "DateField") + .IsUnique(); + + b.ToTable("ScheduledNotificationTracking", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EmailType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("Recipient") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OriginTemplateId") + .HasColumnType("uuid"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.HasIndex("TemplateId"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CancelledBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("CancelledBy"); + + b.Property("CancelledById") + .HasColumnType("uuid") + .HasColumnName("CancelledById"); + + b.Property("CancelledOn") + .HasColumnType("timestamp without time zone") + .HasColumnName("CancelledOn"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Notifications.ScheduledNotification", null) + .WithMany() + .HasForeignKey("ScheduledNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260720172009_AddExceptionLogs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260720172009_AddExceptionLogs.cs new file mode 100644 index 0000000000..e2fbf888d8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260720172009_AddExceptionLogs.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + /// + public partial class AddExceptionLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + migrationBuilder.CreateTable( + name: "ExceptionLogs", + schema: null, + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: true), + UserId = table.Column(type: "uuid", nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + TenantName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NotificationType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Channel = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Severity = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Message = table.Column(type: "text", nullable: false), + Source = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + SourceReference = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + PayloadJson = table.Column(type: "jsonb", nullable: true), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + OccurrenceCount = table.Column(type: "integer", nullable: false, defaultValue: 1), + IsDeliveredRealtime = table.Column(type: "boolean", nullable: false), + DeliveryTarget = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExceptionType = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExceptionMessage = table.Column(type: "text", nullable: true), + StackExcerpt = table.Column(type: "text", nullable: true), + SourceFile = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + SourceLine = table.Column(type: "integer", nullable: true), + CommitSha = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + Environment = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + BlameAuthor = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + BlameEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + BlameCommitSha = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + BlameCommitMessage = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + PullRequestUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + PullRequestNumber = table.Column(type: "integer", nullable: true), + PullRequestTitle = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + TicketReference = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + ExtraProperties = table.Column(type: "text", nullable: false), + ConcurrencyStamp = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CreationTime = table.Column(type: "timestamp without time zone", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: true), + LastModificationTime = table.Column(type: "timestamp without time zone", nullable: true), + LastModifierId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExceptionLogs", x => x.Id); + }); + + + migrationBuilder.CreateIndex( + name: "IX_ExceptionLogs_CorrelationId", + table: "ExceptionLogs", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_ExceptionLogs_NotificationType_CreationTime", + table: "ExceptionLogs", + columns: new[] { "NotificationType", "CreationTime" }); + + migrationBuilder.CreateIndex( + name: "IX_ExceptionLogs_TenantId_CreationTime", + table: "ExceptionLogs", + columns: new[] { "TenantId", "CreationTime" }); + + migrationBuilder.CreateIndex( + name: "IX_ExceptionLogs_TicketReference", + table: "ExceptionLogs", + column: "TicketReference"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + migrationBuilder.DropTable( + name: "ExceptionLogs"); + + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index 4f39d395ab..b4cd000113 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -623,12 +623,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("LastModifierId"); - b.Property("MetadataJson") - .HasColumnType("jsonb"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) .HasColumnType("character varying(200)"); b.Property("SystemPrompt") @@ -648,8 +648,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("TenantId", "Name", "VersionNumber") - .IsUnique(); + b.HasIndex("TenantId", "Name", "VersionNumber") + .IsUnique(); b.ToTable("AIPrompts", "AI"); }); @@ -708,8 +708,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); - b.Property("ApplicationId") - .HasColumnType("uuid"); + b.Property("ApplicationId") + .HasColumnType("uuid"); b.Property("CompletedAt") .HasColumnType("timestamp without time zone"); @@ -729,28 +729,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("CreatorId"); - b.Property("ExtraProperties") - .IsRequired() - .HasColumnType("text") - .HasColumnName("ExtraProperties"); + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); b.Property("FailureReason") .HasMaxLength(2000) .HasColumnType("character varying(2000)"); - b.Property("LastModificationTime") - .HasColumnType("timestamp without time zone") - .HasColumnName("LastModificationTime"); - - b.Property("LastModifierId") - .HasColumnType("uuid") - .HasColumnName("LastModifierId"); - - b.Property("OperationId") - .HasColumnType("uuid"); - - b.Property("StartedAt") - .HasColumnType("timestamp without time zone"); + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp without time zone"); b.Property("Status") .HasColumnType("integer"); @@ -763,7 +763,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OperationId"); - b.HasIndex("TenantId", "ApplicationId", "OperationId", "Status"); + b.HasIndex("TenantId", "ApplicationId", "OperationId", "Status"); b.ToTable("AIRequests", "AI"); }); @@ -1200,6 +1200,175 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SubSectors", (string)null); }); + modelBuilder.Entity("Unity.GrantManager.Logs.ExceptionLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlameAuthor") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BlameCommitMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("BlameCommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("BlameEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeliveryTarget") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Environment") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExceptionMessage") + .HasColumnType("text"); + + b.Property("ExceptionType") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeliveredRealtime") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OccurrenceCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestTitle") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PullRequestUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceFile") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceLine") + .HasColumnType("integer"); + + b.Property("SourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StackExcerpt") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TicketReference") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TicketReference"); + + b.HasIndex("NotificationType", "CreationTime"); + + b.HasIndex("TenantId", "CreationTime"); + + b.ToTable("ExceptionLogs", (string)null); + }); + modelBuilder.Entity("Unity.GrantManager.Messaging.InboxMessage", b => { b.Property("Id") diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs index bfb6de20b7..bd27c0bfb6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs @@ -26,7 +26,7 @@ public void OnAuthorization(AuthorizationFilterContext context) var apiKey = configuration["B2BAuth:ApiKey"]; - if (apiKey is null) + if (string.IsNullOrWhiteSpace(apiKey)) { context.Result = new UnauthorizedObjectResult(new ProblemDetails { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/FormSubmission/FormsApiTokenAuthFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/FormSubmission/FormsApiTokenAuthFilter.cs index e2118fbe85..d627eb346f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/FormSubmission/FormsApiTokenAuthFilter.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/FormSubmission/FormsApiTokenAuthFilter.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc.Filters; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; using Volo.Abp.MultiTenancy; using Volo.Abp.TenantManagement; using Unity.GrantManager.ApplicationForms; @@ -15,23 +17,41 @@ public class FormsApiTokenAuthFilter : IAsyncAuthorizationFilter private readonly ITenantRepository _tenantRepository; private readonly ICurrentTenant _currentTenant; private readonly IApplicationFormTokenAppService _formTokenAppService; - private readonly IEnumerable _formIdResolvers; + private readonly IEnumerable _formIdResolvers; + private readonly IHostEnvironment _hostEnvironment; public FormsApiTokenAuthFilter(ITenantRepository tenantRepository, ICurrentTenant currentTenant, IApplicationFormTokenAppService formTokenAppService, - IEnumerable formIdResolvers) + IEnumerable formIdResolvers, + IHostEnvironment hostEnvironment) { _currentTenant = currentTenant; _tenantRepository = tenantRepository; _formTokenAppService = formTokenAppService; _formIdResolvers = formIdResolvers; + _hostEnvironment = hostEnvironment; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { var apiToken = await GetTenantApiTokenAsync(); - if (apiToken == null) { return; } // No API auth tokens setup for the tenant + if (string.IsNullOrWhiteSpace(apiToken)) + { + if (_hostEnvironment.IsDevelopment()) + { + return; // Dev-only convenience: unconfigured tenants pass through locally + } + + context.Result = new UnauthorizedObjectResult(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = "API authentication not configured for this tenant", + Type = "https://tools.ietf.org/html/rfc7235#section-3.1" + }); + return; + } if (!context.HttpContext.Request.Headers.TryGetValue(AuthConstants.ApiKeyHeader, out var extractedApiToken)) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs new file mode 100644 index 0000000000..75a9c2bfa0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.Web.Controllers.Monitoring; + +public class AlertManagerPayload +{ + [JsonPropertyName("receiver")] + public string Receiver { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + private List _alerts = []; + + [JsonPropertyName("alerts")] + public List Alerts + { + get => _alerts; + set => _alerts = value ?? []; + } +} + +public class AlertItem +{ + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + private Dictionary _labels = []; + private Dictionary _annotations = []; + + [JsonPropertyName("labels")] + public Dictionary Labels + { + get => _labels; + set => _labels = value ?? []; + } + + [JsonPropertyName("annotations")] + public Dictionary Annotations + { + get => _annotations; + set => _annotations = value ?? []; + } + + [JsonPropertyName("startsAt")] + public DateTimeOffset StartsAt { get; set; } + + [JsonPropertyName("generatorURL")] + public string GeneratorURL { get; set; } = string.Empty; + + [JsonPropertyName("fingerprint")] + public string Fingerprint { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs new file mode 100644 index 0000000000..a2d937fe3d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Notifications; +using Volo.Abp.AspNetCore.Mvc; +using Unity.GrantManager.Notifications.Logs; +using Unity.GrantManager.Web.Identity.Policy; + +namespace Unity.GrantManager.Web.Controllers.Monitoring; + +[ApiController] +[Route("api/monitoring")] +[Authorize(Policy = PolicyRegistrant.MetricsAccessPolicy)] +[IgnoreAntiforgeryToken] +public class AlertWebhookController( + INotificationsAppService notificationsAppService, + ILogger logger) : AbpController +{ + /// + /// Receives Alertmanager webhook payloads and forwards a concise summary to Teams. + /// + [HttpPost("alert")] + public async Task ProcessAlert([FromBody] AlertManagerPayload? payload) + { + if (payload is null || !ModelState.IsValid || payload.Alerts.Count == 0) + { + return BadRequest(); + } + + try + { + var firing = payload.Alerts + .Where(a => a is not null && string.Equals(a.Status, "firing", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (firing.Count == 0) + { + return Ok(); + } + + // Pick the most severe alert as the headline (critical > error > warning > info > unknown) + var lead = firing + .OrderBy(a => SeverityOrder(a.Labels.GetValueOrDefault("severity", "unknown"))) + .First(); + string alertName = lead.Labels.GetValueOrDefault("alertname", "Unknown Alert"); + string severity = lead.Labels.GetValueOrDefault("severity", "unknown"); + string summary = lead.Annotations.GetValueOrDefault("summary", alertName); + string description = lead.Annotations.GetValueOrDefault("description", string.Empty); + string @namespace = lead.Labels.GetValueOrDefault("kubernetes_namespace_name", + lead.Labels.GetValueOrDefault("namespace", string.Empty)); + string endpoint = lead.Labels.GetValueOrDefault("handler", + lead.Labels.GetValueOrDefault("endpoint", string.Empty)); + string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + string activityTitle = $"[{severity.ToUpperInvariant()}] {summary}"; + string activitySubtitle = $"Environment: {envInfo} | Namespace: {@namespace}"; + + var facts = new List(); + + if (!string.IsNullOrEmpty(description)) + { + facts.Add(new Fact { Name = "Description", Value = description }); + } + + if (firing.Count > 1) + { + facts.Add(new Fact { Name = "Firing alerts", Value = firing.Count.ToString() }); + } + + if (!string.IsNullOrEmpty(endpoint)) + { + facts.Add(new Fact { Name = "Affected endpoint", Value = endpoint }); + } + + facts.Add(new Fact { Name = "First seen", Value = lead.StartsAt.ToString("u") }); + + if (!string.IsNullOrEmpty(lead.GeneratorURL)) + { + facts.Add(new Fact { Name = "Source", Value = lead.GeneratorURL }); + } + + await notificationsAppService.PostToNotificationsAsync(activityTitle, activitySubtitle, facts); + + return Ok(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to forward alert {AlertName} to Teams", + payload.Alerts.FirstOrDefault()?.Labels?.GetValueOrDefault("alertname")); + return StatusCode(500); + } + } + + private static int SeverityOrder(string? severity) => severity?.ToLowerInvariant() switch + { + "critical" => 0, + "error" => 1, + "warning" => 2, + "info" => 3, + _ => 4 + }; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index fff250360e..88885d73d9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.CookiePolicy; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Localization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -62,6 +63,7 @@ using Volo.Abp.Modularity; using Volo.Abp.OpenIddict.Tokens; using Volo.Abp.SecurityLog; +using Volo.Abp.Security.Claims; using Volo.Abp.SettingManagement.Web; using Volo.Abp.SettingManagement.Web.Pages.SettingManagement; using Volo.Abp.Swashbuckle; @@ -78,6 +80,7 @@ using Unity.Reporting.Web; using Unity.AI.Web; using Unity.GrantManager.Web.Views.Settings; +using Prometheus; namespace Unity.GrantManager.Web; @@ -142,7 +145,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) ConfgureFormsApiAuhentication(context); ConfigureAuthentication(context, configuration); - ConfigurePolicies(context); + ConfigurePolicies(context, configuration); ConfigureUrls(configuration); ConfigureBundles(); ConfigureAutoMapper(); @@ -154,6 +157,46 @@ public override void ConfigureServices(ServiceConfigurationContext context) ConfigureDataProtection(context, configuration); ConfigureMiniProfiler(context, configuration); + // Trust forwarded client IP headers only from explicitly configured ingress/router addresses. + // This ensures RemoteIpAddress reflects the real client IP only when the request came + // through a known proxy, so IP-based checks such as the /metrics policy cannot be spoofed + // by arbitrary internal callers. + var knownForwardedHeaderProxies = configuration + .GetSection("ForwardedHeaders:KnownProxies") + .Get() ?? Array.Empty(); + var knownForwardedHeaderNetworks = configuration + .GetSection("ForwardedHeaders:KnownNetworks") + .Get() ?? Array.Empty(); + + context.Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedProto; + options.ForwardLimit = 1; + options.KnownProxies.Clear(); + options.KnownIPNetworks.Clear(); + + foreach (var proxy in knownForwardedHeaderProxies) + { + if (!string.IsNullOrWhiteSpace(proxy) && System.Net.IPAddress.TryParse(proxy, out var proxyAddress)) + { + options.KnownProxies.Add(proxyAddress); + } + } + + foreach (var network in knownForwardedHeaderNetworks) + { + if (!string.IsNullOrWhiteSpace(network) && System.Net.IPNetwork.TryParse(network, out var ipNetwork)) + { + options.KnownIPNetworks.Add(ipNetwork); + } + } + + if (options.KnownProxies.Count > 0 || options.KnownIPNetworks.Count > 0) + { + options.ForwardedHeaders |= ForwardedHeaders.XForwardedFor; + } + }); + Configure(options => { options.TokenCookie.Expiration = TimeSpan.FromDays(365); @@ -220,8 +263,8 @@ public override void ConfigureServices(ServiceConfigurationContext context) Configure(options => { + options.Contributors.Add(new BackgroundJobsPageContributor()); options.Contributors.Add(new TagManagementPageContributor()); - options.Contributors.Add(new ApplicationUiSettingPageContributor()); }); context.Services.AddHealthChecks() @@ -232,6 +275,11 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.AddHealthChecks() .AddCheck("startup", tags: _startupHealthCheckTags); + + Configure(options => + { + options.Contributors.Add(new ApplicationUiSettingPageContributor()); + }); } private static void ConfigureDataProtection(ServiceConfigurationContext context, IConfiguration configuration) @@ -274,13 +322,46 @@ private static void ConfgureFormsApiAuhentication(ServiceConfigurationContext co context.Services.AddScoped(); } - private static void ConfigurePolicies(ServiceConfigurationContext context) + private static void ConfigurePolicies(ServiceConfigurationContext context, IConfiguration configuration) { + context.Services.AddScoped(); + context.Services.AddSingleton(); + context.Services.AddTransient(); + + context.Services.AddHttpClient() + .ConfigureHttpClient(client => + { + string pat = + Environment.GetEnvironmentVariable("UNITY_GITHUB_PAT") + ?? configuration["UNITY_GITHUB_PAT"] + ?? ""; + + if (!string.IsNullOrWhiteSpace(pat)) + { + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", pat); + } + }); + PolicyRegistrant.Register(context); + PermissionOrPolicyRegistrant.Register(context); } private static void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration) { + context.Services.Configure(options => + { + options.IsDynamicClaimsEnabled = true; //set it "true" to enable "Dynamic Claims" or "false" to disable it. + }); + + // NOTE: AbpClaimTypes.Role must stay a DISTINCT claim type from UnityClaimsTypes.Role + // ("client_roles"). ABP's dynamic claims refresh (AbpDynamicClaimsPrincipalContributorBase) + // recomputes AbpClaimTypes.Role claims purely from the user's real DB IdentityUserRole + // assignments and REPLACES (RemoveAll + re-add) whatever was there. Keycloak-only roles + // (ITAdministrator/ITOperations) are never DB roles, so if the two claim types were unified, + // every dynamic-claims refresh would wipe them out. Unifying them was tried as a cookie-size + // optimization (one claim per role instead of two) but reverted for this reason. + context.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; @@ -407,7 +488,6 @@ private void ConfigureBundles() bundle.AddFiles("/global-styles.css"); }); - options.StyleBundles.Configure( NotificationsBundles.Styles.Notifications, bundle => @@ -550,11 +630,18 @@ public override void OnApplicationInitialization(ApplicationInitializationContex var env = context.GetEnvironment(); var configuration = context.GetConfiguration(); + ErrorCountingLoggerSink.SetScopeFactory( + app.ApplicationServices.GetRequiredService()); + if (!env.IsProduction()) { IdentityModelEventSource.ShowPII = true; } + // Rewrite RemoteIpAddress from X-Forwarded-For before any IP-based checks run. + // Trusted networks are configured in ConfigureServices above. + app.UseForwardedHeaders(); + app.UseAbpRequestLocalization(); if (env.IsProduction() || env.IsStaging()) @@ -587,8 +674,10 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseCorrelationId(); app.UseStaticFiles(); + app.UseMiddleware(); app.UseMiddleware(); app.UseRouting(); + app.UseHttpMetrics(); app.UseAuthentication(); if (MultiTenancyConsts.IsEnabled) @@ -597,6 +686,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex } app.UseUnitOfWork(); + app.UseDynamicClaims(); app.UseAuthorization(); if (IsProfilingAllowed(env, configuration)) { @@ -609,8 +699,10 @@ public override void OnApplicationInitialization(ApplicationInitializationContex }); app.UseAuditing(); app.UseAbpSerilogEnrichers(); - app.UseMiddleware(); - app.UseConfiguredEndpoints(); + app.UseConfiguredEndpoints(endpoints => + { + endpoints.MapMetrics().RequireAuthorization(Unity.GrantManager.Web.Identity.Policy.PolicyRegistrant.MetricsAccessPolicy); + }); var supportedCultures = new[] { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs new file mode 100644 index 0000000000..90f1034f54 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs @@ -0,0 +1,39 @@ +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using System.Threading.Tasks; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.Identity.Authorization; + +public class PermissionOrRequirement : IAuthorizationRequirement +{ + public string[] Permissions { get; } + + public PermissionOrRequirement(params string[] permissions) + { + Permissions = permissions; + } +} + +public class PermissionOrAuthorizationHandler : AuthorizationHandler, ITransientDependency +{ + private readonly IPermissionChecker _permissionChecker; + + public PermissionOrAuthorizationHandler(IPermissionChecker permissionChecker) + { + _permissionChecker = permissionChecker; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionOrRequirement requirement) + { + var result = await _permissionChecker.IsGrantedAsync(context.User, requirement.Permissions); + + if (result.Result.Any(r => r.Value == PermissionGrantResult.Granted)) + { + context.Succeed(requirement); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs new file mode 100644 index 0000000000..cbb607da22 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs @@ -0,0 +1,45 @@ +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using System.Threading.Tasks; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.Identity.Authorization; + +public class RoleOrPermissionRequirement : IAuthorizationRequirement +{ + public string[] RoleNames { get; } + public string PermissionName { get; } + + public RoleOrPermissionRequirement(string[] roleNames, string permissionName) + { + RoleNames = roleNames; + PermissionName = permissionName; + } +} + +public class RoleOrPermissionAuthorizationHandler : AuthorizationHandler, ITransientDependency +{ + private readonly IPermissionChecker _permissionChecker; + + public RoleOrPermissionAuthorizationHandler(IPermissionChecker permissionChecker) + { + _permissionChecker = permissionChecker; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + RoleOrPermissionRequirement requirement) + { + if (requirement.RoleNames.Any(context.User.IsInRole)) + { + context.Succeed(requirement); + return; + } + + if (await _permissionChecker.IsGrantedAsync(context.User, requirement.PermissionName)) + { + context.Succeed(requirement); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs index ab17dea528..5e67687d08 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs @@ -75,10 +75,18 @@ public virtual bool IsInRole(string roleName) var userClaims = _principalAccessor.Principal?.Claims; if (userClaims != null && userClaims.Any()) { - var userId = userClaims.FirstOrDefault(s => s.Type == "UserId"); + // UnityClaimsTypes.IDirUserGuid ("idir_user_guid") is Keycloak's IDIR-specific + // identifier, used only to derive the OIDC subject for account matching on login + // (see UserImportAppService) - it is NOT the database user id and must not be used + // here, even though it happens to also be GUID-formatted. + var userId = userClaims.FirstOrDefault(s => s.Type == AbpClaimTypes.UserId); if (userId != null) { - return Guid.Parse(userId.Value); + var value = userId.Value.Split('@')[0]; // Remove @azureidir suffix + if (Guid.TryParse(value, out var guid)) + { + return guid; + } } } return null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs deleted file mode 100644 index 3eb02c46cb..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs +++ /dev/null @@ -1,19 +0,0 @@ -using OpenIddict.Abstractions; -using System.Collections.Immutable; -using System.Security.Claims; - -namespace Unity.GrantManager.Web.Identity -{ - public static class IdentityExtensionMethods - { - public static ClaimsPrincipal AddPermission(this ClaimsPrincipal principal, string value) - { - return principal.AddClaim(UnityClaimsTypes.Permission, value); - } - - public static ClaimsPrincipal AddPermissions(this ClaimsPrincipal principal, ImmutableArray values) - { - return principal.AddClaims(UnityClaimsTypes.Permission, values); - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs index bf5cf48de0..b54a3f5f6b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Abstractions; -using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using System.Security.Principal; using System.Threading.Tasks; using Unity.GrantManager.Identity; using Unity.GrantManager.Web.Identity.LoginHandlers; @@ -45,6 +45,7 @@ internal async Task HandleAsync(TokenValidatedContext validatedTokenContext) } AddTenantClaims(validatedTokenContext.Principal!, userTenantAccounts); + RemoveRawJwtMetadataClaims(validatedTokenContext.Principal!); // Create security log await securityLogManager.SaveAsync(securityLog => @@ -66,5 +67,22 @@ private static void AddTenantClaims(ClaimsPrincipal claimsPrincipal, IList +/// Allows access to /metrics only from loopback or RFC-1918 private addresses. +/// This permits Prometheus to scrape pod-to-pod within the OpenShift cluster +/// while blocking external callers. +///
    +public class InternalNetworkRequirement : IAuthorizationRequirement { } + +public class InternalNetworkHandler(IHttpContextAccessor httpContextAccessor) + : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + InternalNetworkRequirement requirement) + { + var remoteIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress; + + if (remoteIp is null) + { + context.Fail(); + return Task.CompletedTask; + } + + // Map IPv4-in-IPv6 (::ffff:x.x.x.x) back to IPv4 for range checks + if (remoteIp.IsIPv4MappedToIPv6) + { + remoteIp = remoteIp.MapToIPv4(); + } + + if (IsAllowed(remoteIp)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + + private static bool IsAllowed(IPAddress ip) + { + if (IPAddress.IsLoopback(ip)) return true; + + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = ip.GetAddressBytes(); + + // 10.0.0.0/8 + if (bytes[0] == 10) return true; + + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) return true; + } + + return false; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs index c80469dbd5..4d6250ebf3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs @@ -1,13 +1,10 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; using Unity.Modules.Shared.Permissions; -using Unity.TenantManagement; using Volo.Abp; using Volo.Abp.Data; using Volo.Abp.Identity; @@ -16,18 +13,6 @@ namespace Unity.GrantManager.Web.Identity.LoginHandlers { internal class IdentityProfileLoginAdminHandler : IdentityProfileLoginBase { - internal readonly ImmutableArray _adminPermissions = ImmutableArray.Create( - TenantManagementPermissions.Tenants.Default, - TenantManagementPermissions.Tenants.Create, - TenantManagementPermissions.Tenants.Update, - TenantManagementPermissions.Tenants.Delete, - TenantManagementPermissions.Tenants.ManageFeatures, - TenantManagementPermissions.Tenants.ManageConnectionStrings, - IdentityPermissions.Users.Create, - IdentityPermissions.UserLookup.Default, - IdentityConsts.ITAdminPermissionName - ); - internal async Task Handle(TokenValidatedContext validatedTokenContext, IList userTenantAccounts, string? idp) @@ -43,8 +28,11 @@ internal async Task Handle(TokenValidatedContext validated userTenantAccount = userTenantAccounts.First(s => s.TenantId == null); } - AssignAdminHostPermissions(validatedTokenContext.Principal!); AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); + // No explicit role claim stamped here - ITAdministrator/ITOperations role recognition + // relies entirely on the client_roles claim Keycloak already sends natively (the routing + // check at the top of IdentityProfileLoginHandler.HandleAsync depends on that being true + // before this handler even runs). return userTenantAccount; } @@ -63,11 +51,6 @@ private static bool AdminHasUserAccount(IList? userTenantA return false; } - private void AssignAdminHostPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_adminPermissions); - } - private async Task CreateAdminAccountAsync(TokenValidatedContext validatedTokenContext, string? idp) { var token = validatedTokenContext.SecurityToken; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs index 0810de9d47..59c76fa0ec 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System; +using System.Security.Principal; using Unity.GrantManager.Identity; using OpenIddict.Abstractions; using System.IdentityModel.Tokens.Jwt; @@ -7,7 +8,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; -using Volo.Abp.PermissionManagement; +using Volo.Abp.Security.Claims; using Microsoft.Extensions.Configuration; using Volo.Abp.TenantManagement; @@ -19,7 +20,6 @@ internal abstract class IdentityProfileLoginBase : ITransientDependency protected ICurrentTenant CurrentTenant => LazyServiceProvider.LazyGetRequiredService(); protected IdentityUserManager IdentityUserManager => LazyServiceProvider.LazyGetRequiredService(); protected IdentityRoleManager IdentityRoleManager => LazyServiceProvider.LazyGetRequiredService(); - protected PermissionManager PermissionManager => LazyServiceProvider.LazyGetRequiredService(); protected IIdentityUserRepository IdentityUserRepository => LazyServiceProvider.LazyGetRequiredService(); protected IConfiguration Configuration => LazyServiceProvider.LazyGetRequiredService(); protected IUserImportAppService UserImportAppService => LazyServiceProvider.LazyGetRequiredService(); @@ -28,8 +28,15 @@ internal abstract class IdentityProfileLoginBase : ITransientDependency protected static void AssignDefaultClaims(ClaimsPrincipal claimsPrinicipal, string displayName, Guid userId) { + // AbpClaimTypes.UserId is the same claim type URI as ClaimTypes.NameIdentifier, which the + // OIDC/JWT handler already populates from the token's "sub" claim before this runs. Without + // clearing it first, the principal ends up with two UserId claims (Keycloak's sub, then ours), + // and CurrentUser.FindUserId()'s FirstOrDefault picks the wrong (sub) one. + var identity = claimsPrinicipal.Identity as ClaimsIdentity; + identity?.RemoveAll(AbpClaimTypes.UserId); + claimsPrinicipal.AddClaim("DisplayName", displayName); - claimsPrinicipal.AddClaim("UserId", userId.ToString()); + claimsPrinicipal.AddClaim(AbpClaimTypes.UserId, userId.ToString()); claimsPrinicipal.AddClaim("Badge", Utils.CreateUserBadge(displayName)); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs index a13314a84e..57f773dac1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs @@ -4,32 +4,17 @@ using OpenIddict.Abstractions; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; -using Unity.GrantManager.Permissions; using Unity.GrantManager.Web.Exceptions; -using Unity.Modules.Shared.Permissions; -using Volo.Abp.Identity; -using Volo.Abp.PermissionManagement; using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Web.Identity.LoginHandlers { internal class IdentityProfileLoginUserHandler : IdentityProfileLoginBase { - internal readonly ImmutableArray _userPermissions = [ - GrantManagerPermissions.Default, - IdentityPermissions.UserLookup.Default - ]; - - internal readonly ImmutableArray _itOperationsPermissions = [ - GrantManagerPermissions.Endpoints.ManageEndpoints, - IdentityConsts.ITOperationsPermissionName - ]; - internal async Task Handle(TokenValidatedContext validatedTokenContext, IList? userTenantAccounts, string? idp) @@ -57,11 +42,6 @@ internal async Task Handle(TokenValidatedContext validated } } - if (validatedTokenContext.Principal != null && validatedTokenContext.Principal.IsInRole(IdentityConsts.ITOperationsRoleName)) - { - AssignITOperationsPermissions(validatedTokenContext.Principal); - } - UserTenantAccountDto? userTenantAccount = null; var setTenant = validatedTokenContext.Request.Cookies["set_tenant"]; if (setTenant != null && setTenant != Guid.Empty.ToString()) @@ -72,32 +52,12 @@ internal async Task Handle(TokenValidatedContext validated } userTenantAccount ??= userTenantAccounts[0]; - var principal = validatedTokenContext.Principal!; - - using (CurrentTenant.Change(userTenantAccount.TenantId)) - { - var userRoles = await IdentityUserRepository.GetRolesAsync(userTenantAccount.Id); - - if (userRoles != null) - { - foreach (var role in userRoles) - { - var dbRole = await IdentityRoleManager.GetByIdAsync(role.Id); - principal.AddClaim(UnityClaimsTypes.Role, dbRole.Name); - } - } - - var userPermissions = (await PermissionManager.GetAllForUserAsync(userTenantAccount.Id)).Where(s => s.IsGranted); - foreach (var permissionName in userPermissions - .Select(s => s.Name) - .Where(permissionName => !principal.HasClaim(UnityClaimsTypes.Permission, permissionName))) - { - principal.AddClaim(UnityClaimsTypes.Permission, permissionName); - } - } - - AssignDefaultPermissions(validatedTokenContext.Principal!); + // DB-managed roles (approver, assessor, program_manager, etc.) are no longer stamped + // onto the principal here - ABP's dynamic claims refresh recomputes AbpClaimTypes.Role + // from the DB on every request, so the cookie doesn't need to carry them. The + // UnityClaimsTypes.Role ("client_roles") claim now only ever holds what Keycloak sends + // natively (ITAdministrator/ITOperations), which ABP can't recompute. AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); validatedTokenContext.Principal!.AddClaim(AbpClaimTypes.TenantId, userTenantAccount.TenantId?.ToString() ?? Guid.Empty.ToString()); @@ -105,11 +65,6 @@ internal async Task Handle(TokenValidatedContext validated return userTenantAccount; } - private void AssignITOperationsPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_itOperationsPermissions); - } - private async Task> AutoRegisterUserWithDefaultAsync(string userIdentifier, string username, string firstName, @@ -151,10 +106,5 @@ private bool IsAutoRegisterFlagSet() { return Configuration.GetValue("IdentityProfileLogin:AutoCreateUser"); } - - private void AssignDefaultPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_userPermissions); - } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs deleted file mode 100644 index 53ff40213a..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Linq; -using System.Security.Claims; -using System.Security.Principal; -using System.Threading.Tasks; -using Volo.Abp; -using Volo.Abp.Authorization.Permissions; -using Volo.Abp.DependencyInjection; -using Volo.Abp.MultiTenancy; -using Volo.Abp.Security.Claims; -using Volo.Abp.SimpleStateChecking; - -namespace Unity.GrantManager.Web.Identity -{ - [Dependency(ReplaceServices = true)] - [ExposeServices(typeof(PermissionChecker), typeof(IPermissionChecker))] - public class PermissionChecker : IPermissionChecker, ITransientDependency - { - protected IPermissionDefinitionManager PermissionDefinitionManager { get; } - protected ICurrentPrincipalAccessor PrincipalAccessor { get; } - protected ICurrentTenant CurrentTenant { get; } - protected IPermissionValueProviderManager PermissionValueProviderManager { get; } - protected ISimpleStateCheckerManager StateCheckerManager { get; } - - public PermissionChecker( - ICurrentPrincipalAccessor principalAccessor, - IPermissionDefinitionManager permissionDefinitionManager, - ICurrentTenant currentTenant, - IPermissionValueProviderManager permissionValueProviderManager, - ISimpleStateCheckerManager stateCheckerManager) - { - PrincipalAccessor = principalAccessor; - PermissionDefinitionManager = permissionDefinitionManager; - CurrentTenant = currentTenant; - PermissionValueProviderManager = permissionValueProviderManager; - StateCheckerManager = stateCheckerManager; - } - - public virtual async Task IsGrantedAsync(string name) - { - return await IsGrantedAsync(PrincipalAccessor.Principal, name); - } - - public virtual async Task IsGrantedAsync( - ClaimsPrincipal? claimsPrincipal, - string name) - { - Check.NotNull(name, nameof(name)); - - var permission = await PermissionDefinitionManager.GetOrNullAsync(name); - if (permission == null) - { - return false; - } - - if (!permission.IsEnabled) - { - return false; - } - - if (!await StateCheckerManager.IsEnabledAsync(permission)) - { - return false; - } - - var multiTenancySide = claimsPrincipal?.GetMultiTenancySide() - ?? CurrentTenant.GetMultiTenancySide(); - - if (!permission.MultiTenancySide.HasFlag(multiTenancySide)) - { - return false; - } - - var isGranted = false; - - if (claimsPrincipal != null - && claimsPrincipal.Claims.Any(s => s.Type == "Permission" && s.Value == name)) - { - isGranted = true; - } - - return isGranted; - } - - public async Task IsGrantedAsync(string[] names) - { - return await IsGrantedAsync(PrincipalAccessor.Principal, names); - } - - public async Task IsGrantedAsync(ClaimsPrincipal? claimsPrincipal, string[] names) - { - Check.NotNull(names, nameof(names)); - - var result = new MultiplePermissionGrantResult(); - if (names.Length == 0) - { - return result; - } - - if (claimsPrincipal != null) - { - var permissions = claimsPrincipal.Claims.Where(s => s.Type == "Permission"); - foreach (var name in names) - { - if (permissions.Select(s => s.Value).Contains(name)) - { - result.Result.Add(name, PermissionGrantResult.Granted); - } - else - { - result.Result.Add(name, PermissionGrantResult.Prohibited); - } - } - } - - return await Task.FromResult(result); - } - } -} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionOrPolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionOrPolicyRegistrant.cs new file mode 100644 index 0000000000..89bc5d623d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionOrPolicyRegistrant.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using Unity.GrantManager.Web.Identity.Authorization; +using Unity.Modules.Shared; +using Volo.Abp.Modularity; + +namespace Unity.GrantManager.Web.Identity.Policy; + +// Composite "any of these permissions" policies - no Keycloak role involved. +// TODO: remove once the underlying permissions are consolidated so a single +// real permission can be checked directly instead of an OR across several. +internal static class PermissionOrPolicyRegistrant +{ + internal static void Register(ServiceConfigurationContext context) + { + var authorizationBuilder = context.Services.AddAuthorizationBuilder(); + + // Applicant Info Logical OR policy + authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, + policy => policy.AddRequirements(new PermissionOrRequirement( + UnitySelector.Applicant.Summary.Update, + UnitySelector.Applicant.Contact.Update, + UnitySelector.Applicant.Authority.Update, + UnitySelector.Applicant.Location.Update, + UnitySelector.Applicant.AdditionalContact.Update, + + // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Applicant.Worksheet.Update + UnitySelector.Applicant.Default))); + + // Project Info Logical OR policy + authorizationBuilder.AddPolicy(UnitySelector.Project.UpdatePolicy, + policy => policy.AddRequirements(new PermissionOrRequirement( + UnitySelector.Project.Location.Update.Default, + UnitySelector.Project.Summary.Update.Default, + + // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Project.Worksheet.Update + UnitySelector.Project.Default))); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 95f9393ef3..2a94b89bde 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -1,8 +1,6 @@ using Microsoft.Extensions.DependencyInjection; -using Unity.GrantManager.Permissions; -using Unity.Modules.Shared; +using Unity.GrantManager.Web.Identity.Authorization; using Unity.Modules.Shared.Permissions; -using Unity.Reporting.Permissions; using Unity.TenantManagement; using Volo.Abp.Identity; using Volo.Abp.Modularity; @@ -11,271 +9,78 @@ namespace Unity.GrantManager.Web.Identity.Policy; internal static class PolicyRegistrant { - internal const string PermissionConstant = "Permission"; + // IT Administrator is the "host" superuser login (see IdentityProfileLoginAdminHandler) - + // it must retain at least the host/tenant-admin access ITOperations has, plus a few + // admin-only permissions (user creation/lookup, tenant delete/connection strings) that + // used to be granted via a hardcoded claim stamp at login (_adminPermissions, removed + // when cookie-stamped permission claims were dropped in favour of IPermissionChecker). + private static readonly string[] ITAdminOrITOperationsRoles = + [IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName]; + internal const string MetricsAccessPolicy = "MetricsAccess"; internal static void Register(ServiceConfigurationContext context) { - // Using AddAuthorizationBuilder to register authorization services and construct policies + // All permission-based policies (single permission or otherwise) are resolved + // dynamically by ABP's AbpAuthorizationPolicyProvider via IPermissionChecker + // (Redis-cached). Only policies that need to check a Keycloak-issued role claim + // (IsInRole) need explicit registration here. var authorizationBuilder = context.Services.AddAuthorizationBuilder(); - // Identity Role Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.ManagePermissions)); + // Metrics endpoint — allow only loopback / RFC-1918 (cluster-internal) callers + authorizationBuilder.AddPolicy(MetricsAccessPolicy, + policy => policy.AddRequirements(new InternalNetworkRequirement())); - // Identity User Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.ManagePermissions)); - - // User Lookup Policies - authorizationBuilder.AddPolicy(IdentityPermissions.UserLookup.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.UserLookup.Default)); - - // Grant Manager Policies - authorizationBuilder.AddPolicy(GrantManagerPermissions.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.Intakes.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Intakes.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.ApplicationForms.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.ApplicationForms.Default)); - - // Grant Application Policies - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applications.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applications.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Edit, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Edit)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.AssignApplicant, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.AssignApplicant)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.AssignInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.AssignInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.StartInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.StartInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.CompleteInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.CompleteInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Complete, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Complete)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Add, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Add)); - - // R&A Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Default)); - - // R&A - Approval Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.UpdateFinalStateFields)); - - // R&A - Assessment Results Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields)); - - // R&A - Assessment Review List Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.SendBack, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.SendBack)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.Complete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.Complete)); - - //-- APPLICANT INFO - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update)); - - // Applicant Info Logical OR policy - authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Location.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update) || - - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Applicant.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Default) - )); - - //-- PAYMENT INFO - authorizationBuilder.AddPolicy(UnitySelector.Payment.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.PaymentList.Default)); - - // Tenancy Policies - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Default, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Default)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Create, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Create)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Update, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Update)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Delete)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageFeatures, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageFeatures) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageConnectionStrings)); - - // Setting Management - Tag Management - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Default)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Create)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Update)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Delete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Delete)); - - // IT Administrator Policies + // IT Administrator / IT Operations role policies authorizationBuilder.AddPolicy(IdentityConsts.ITAdminPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITAdminRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITAdminPermissionName) - )); - - // IT Operations Policies + policy => policy.RequireRole(IdentityConsts.ITAdminRoleName)); authorizationBuilder.AddPolicy(IdentityConsts.ITOperationsPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Tenant management combined: Tenants.Default OR ITOperations + policy => policy.RequireRole(IdentityConsts.ITOperationsRoleName)); + authorizationBuilder.AddPolicy(IdentityConsts.ITAdminOrITOperationsPolicyName, + policy => policy.RequireRole(ITAdminOrITOperationsRoles)); + + // Tenant management combined: Tenants. OR ITAdmin/ITOperations + // NOTE: TenantManagementPermissions.Tenants.Default/Create/Update/Delete/ManageConnectionStrings + // are not real ABP permissions (only ManageFeatures/ManageEndpoints come from the base ABP + // TenantManagement module - see UnityTenantManagementPermissionDefinitionProvider). They're + // referenced directly (not via the TenantsXOrITOps composite names) by some Razor Pages/ + // toolbar conventions in UnityTenantManagementWebModule, so both the raw name and its + // composite-policy equivalent must be registered with the same effective check. + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageFeatures, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.ManageFeatures))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Default, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Default))); authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsOrITOps, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Default) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Tenant management combined: Tenants.Update OR ITOperations + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Default))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Update, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Update))); authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsUpdateOrITOps, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Update) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Tenant management combined: Tenants.Create OR ITOperations + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Update))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Create, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Create))); authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsCreateOrITOps, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Create) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Project Info Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Default)); - - // Project Info Logical OR policy - authorizationBuilder.AddPolicy(UnitySelector.Project.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default) || - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default) || + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Create))); - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Project.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Default) - )); - - // Project Info - Summary Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.UpdateFinalStateFields)); - - // Project Info - Location Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.UpdateFinalStateFields)); - - - // Reporting Configuration - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Default, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Default)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Update, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Update)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Delete, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Delete)); - - // Application - External Status Visibility - authorizationBuilder.AddPolicy(UnitySelector.Application.Status.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Application.Status.Publish, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.Publish)); - authorizationBuilder.AddPolicy(UnitySelector.Application.Status.Unpublish, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.Unpublish)); - authorizationBuilder.AddPolicy(UnitySelector.Application.Status.BulkPublish, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.BulkPublish)); + // ITAdmin-only: Tenant delete/connection-string management and Identity user + // creation/lookup - previously covered by the removed admin claim stamp. + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], TenantManagementPermissions.Tenants.Delete))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], TenantManagementPermissions.Tenants.ManageConnectionStrings))); + authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], IdentityPermissions.Users.Create))); + authorizationBuilder.AddPolicy(IdentityPermissions.UserLookup.Default, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], IdentityPermissions.UserLookup.Default))); } } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs index 0e559fbe83..dd06155606 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs @@ -5,7 +5,6 @@ using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; -using Unity.TenantManagement; using Unity.TenantManagement.Web.Navigation; using Volo.Abp.Identity; using Volo.Abp.UI.Navigation; @@ -35,9 +34,9 @@ await context.AddItemAsync( l["Menu:Onboarding"], "~/TenantManagement/Onboarding", icon: "fl fl-other-user", - order: 1, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName + order: 1 ).OnlyWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITOperationsRoleName) ); await context.AddItemAsync( @@ -117,14 +116,14 @@ await context.AddItemAsync( ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) ); - // Displayed in the Grant Manager - Used at Tenant Level if the user in the IT Operations role + // Displayed in the Grant Manager - Used at Tenant Level for ITAdmin/ITOperations users await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.EndpointManagement, displayName: "Endpoints", - "~/EndpointManagement/Endpoints", - requiredPermissionName: IdentityConsts.ITOperationsPermissionName + "~/EndpointManagement/Endpoints" ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName) ); // ******************** @@ -135,9 +134,9 @@ await context.AddItemAsync( l["Menu:TenantManagement"], "~/TenantManagement/Tenants", icon: "fl fl-view-dashboard", - order: 8, - requiredPermissionName: TenantManagementPermissions.Tenants.Default + order: 8 ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName) ); // Tenants list for ITOperations users on the Onboarding tenant @@ -147,19 +146,9 @@ await context.AddItemAsync( l["Menu:TenantManagement"], "~/TenantManagement/Tenants", icon: "fl fl-view-dashboard", - order: 8, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName + order: 8 ).OnlyWhenSpecializations(SpecializationConsts.Onboarding) - ); - - // Displayed on the Tenant Management area if the user has the ITAdministrator Role - await context.AddItemAsync( - new ApplicationMenuItem( - GrantManagerMenus.EndpointManagement, - displayName: "Endpoints", - "~/EndpointManagement/Endpoints", - requiredPermissionName: TenantManagementPermissions.Tenants.Default - ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITOperationsRoleName) ); // End Admin ******************** diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs new file mode 100644 index 0000000000..cf84cb5eed --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -0,0 +1,367 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Notifications; +using Unity.GrantManager.Notifications.Logs; +using Unity.GrantManager.Logs; +using Volo.Abp.ExceptionHandling; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.Web.Middleware; + +/// +/// Hooks into ABP's exception pipeline via IExceptionSubscriber. +/// ABP calls this for every exception it handles (controller actions, app services, etc.) +/// — complementing ExceptionCounterMiddleware which only catches exceptions that bypass ABP. +/// Registered explicitly in GrantManagerWebModule.ConfigureServices. +/// +public class AbpExceptionNotificationSubscriber( + ExceptionNotificationThrottle throttle, + IServiceScopeFactory scopeFactory, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + : IExceptionSubscriber +{ + private static readonly HashSet NotifyEnvironments = + new(StringComparer.OrdinalIgnoreCase) + { + "Production", + "Test", + "Development" + }; + + public Task HandleAsync(ExceptionNotificationContext context) + { + Exception ex = context.Exception; + + logger.LogInformation( + "[ExceptionNotify] Processing exception {ExceptionType}", + ex.GetType().FullName); + + // Increment Prometheus counters + ErrorCountingLoggerSink.ErrorCounter + .WithLabels("error", ex.GetType().Name) + .Inc(); + + // The OpenShift "UnityHighExceptionRate" alert queries application_exceptions_total by + // its "type" label. ExceptionCounterMiddleware only sees exceptions that escape the whole + // pipeline unhandled, so ABP-handled exceptions (the common case) must be counted here too. + ExceptionCounterMiddleware.ExceptionCounter + .WithLabels(ex.GetType().Name) + .Inc(); + + TryQueueLogNotification(ex); + + return Task.CompletedTask; + } + + private void TryQueueLogNotification(Exception ex) + { + string? env = + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + if (string.IsNullOrWhiteSpace(env) || + !NotifyEnvironments.Contains(env)) + { + return; + } + + if (!throttle.ShouldNotify(ex.GetType().Name)) + { + logger.LogDebug( + "[ExceptionNotify] Notification throttled for {ExceptionType}", + ex.GetType().Name); + + return; + } + + // Fire-and-forget by design. + // Notification failures are handled internally. + _ = SendNotificationAsync(ex, env); + } + + private async Task SendNotificationAsync( + Exception ex, + string environment) + { + try + { + await using AsyncServiceScope scope = + scopeFactory.CreateAsyncScope(); + + IServiceProvider services = scope.ServiceProvider; + + var uowManager = services.GetRequiredService(); + var notifications = + services.GetRequiredService(); + var exceptionLogs = + services.GetService(); + + string endpoint = GetEndpoint(); + + var frame = + ExceptionNotificationHelpers.GetTopFrame(ex); + + string sourceFile = + ExceptionNotificationHelpers.NormalizeRepoPath( + frame?.File ?? "(unknown)"); + + int? sourceLine = frame?.Line; + + Guid? userId = AbpUserTenantAccessor.GetCurrentUserId(services); + string userName = AbpUserTenantAccessor.GetCurrentUserName(services) ?? "unknown"; + string tenantName = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(services) ?? "unknown"; + + string activityTitle = + $"[{environment.ToUpperInvariant()}] {ex.GetType().Name}"; + + string activitySubtitle = + $"Environment: {environment} | {endpoint} | {userName}@{tenantName}"; + + List facts = BuildFacts( + ex, + endpoint, + userName, + tenantName, + sourceFile, + sourceLine); + GitHubBlameInfo? blame = await EnrichWithBlameInfoAsync( + services, + facts, + sourceFile, + sourceLine); + + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + + // Try to post notification, but don't let failures prevent exception logging + try + { + await notifications.PostToNotificationsAsync( + activityTitle, + activitySubtitle, + facts); + } + catch (Exception notificationEx) + { + logger.LogWarning(notificationEx, "Failed to post notification to Teams"); + } + + // Always attempt to log the exception, even if notification failed + if (exceptionLogs != null) + { + try + { + await exceptionLogs.CreateAsync(new CreateExceptionLogDto + { + UserId = userId, + UserName = userName, + TenantName = tenantName, + NotificationType = ExceptionLogType.AbpHandledException, + Channel = ExceptionLogChannel.ExceptionPipeline, + Severity = ExceptionLogSeverity.Error, + Title = activityTitle, + Message = ex.Message, + Source = nameof(AbpExceptionNotificationSubscriber), + SourceReference = endpoint, + CorrelationId = httpContextAccessor.HttpContext?.TraceIdentifier, + IsDeliveredRealtime = false, + ExceptionType = ex.GetType().FullName ?? ex.GetType().Name, + ExceptionMessage = ex.Message, + StackExcerpt = ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex), + SourceFile = sourceFile, + SourceLine = sourceLine, + CommitSha = ExceptionCounterMiddleware.CommitSha, + Environment = environment, + PayloadJson = null, + BlameAuthor = blame?.Author, + BlameEmail = blame?.Email, + BlameCommitSha = blame?.CommitSha, + BlameCommitMessage = blame?.Message, + PullRequestUrl = blame?.PullRequestUrl, + PullRequestNumber = blame?.PullRequestNumber, + PullRequestTitle = blame?.PullRequestTitle, + TicketReference = ExceptionNotificationHelpers.ExtractTicketReference(blame?.PullRequestTitle) + }); + } + catch (Exception logEx) + { + logger.LogWarning(logEx, "Failed to create exception log in ABP exception subscriber"); + } + } + + await uow.CompleteAsync(); + } + catch (Exception notificationException) + { + logger.LogWarning( + notificationException, + "Failed to send Teams exception notification"); + } + } + + private string GetEndpoint() + { + HttpContext? httpContext = httpContextAccessor.HttpContext; + + if (httpContext == null) + { + return "(background)"; + } + + return $"{httpContext.Request.Method} {httpContext.Request.Path}"; + } + + private static List BuildFacts( + Exception ex, + string endpoint, + string userName, + string tenantName, + string sourceFile, + int? sourceLine) + { + string exceptionType = + ex.GetType().FullName ?? ex.GetType().Name; + + string innerMessage = + ex.InnerException?.Message ?? string.Empty; + + string stackTrace = + ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); + + return ExceptionNotificationHelpers.BuildFacts( + exceptionType, + ex.Message, + endpoint, + userName, + tenantName, + stackTrace, + sourceFile, + sourceLine, + ExceptionCounterMiddleware.CommitSha, + innerMessage); + } + + private async Task EnrichWithBlameInfoAsync( + IServiceProvider services, + List facts, + string sourceFile, + int? sourceLine) + { + if (!sourceLine.HasValue) + { + return null; + } + + try + { + string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); + var blameService = services.GetService(); + if (blameService == null) + { + logger.LogDebug("Blame lookup service not available; skipping blame enrichment for {File}:{Line}", sourceFile, sourceLine); + return null; + } + + GitHubBlameInfo? blame = null; + try + { + blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); + } + catch (Exception innerBlameEx) + { + logger.LogDebug(innerBlameEx, "Blame lookup failed; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); + } + + if (blame == null) + { + return null; + } + + logger.LogInformation( + "[ExceptionNotify] Blame lookup successful: {Author} {Commit}", + blame.Author, + blame.CommitSha); + + AddAuthorFact(facts, blame); + AddCommitFact(facts, blame); + AddPullRequestFacts(facts, blame); + + return blame; + } + catch (Exception blameException) + { + logger.LogWarning( + blameException, + "Failed to enrich exception with GitHub blame information"); + + return null; + } + } + + private static void AddAuthorFact( + ICollection facts, + GitHubBlameInfo blame) + { + facts.Add(new Fact + { + Name = "Author", + Value = $"{blame.Author} <{blame.Email}>" + }); + } + + private static void AddCommitFact( + ICollection facts, + GitHubBlameInfo blame) + { + string shortSha = GetShortSha(blame.CommitSha); + + facts.Add(new Fact + { + Name = "Commit", + Value = $"{shortSha} {blame.Message}" + }); + } + + private static void AddPullRequestFacts( + ICollection facts, + GitHubBlameInfo blame) + { + if (string.IsNullOrWhiteSpace(blame.PullRequestUrl)) + { + return; + } + + facts.Add(new Fact + { + Name = "PR", + Value = + $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" + }); + + if (string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + { + return; + } + + facts.Add(new Fact + { + Name = "PR Title", + Value = blame.PullRequestTitle + }); + } + + private static string GetShortSha(string? sha) + { + if (string.IsNullOrWhiteSpace(sha)) + { + return string.Empty; + } + + return sha.Length > 7 + ? sha[..7] + : sha; + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs index 09131c0257..e43eb9cd33 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs @@ -21,5 +21,10 @@ internal static class AbpUserTenantAccessor { return SharedKernel.Utilities.AbpUserTenantAccessor.GetCurrentTenantId(serviceProvider); } + + public static Guid? GetCurrentUserId(IServiceProvider serviceProvider) + { + return SharedKernel.Utilities.AbpUserTenantAccessor.GetCurrentUserId(serviceProvider); + } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs new file mode 100644 index 0000000000..015aa2d1f1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs @@ -0,0 +1,99 @@ +using Prometheus; +using Serilog.Core; +using Serilog.Events; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Unity.GrantManager.Logs; + +namespace Unity.GrantManager.Web.Middleware; + +/// +/// Shared Prometheus counter for application-level errors. +/// Labelled by log level ("error" / "fatal") and exception type (empty when no exception). +/// Implemented as a Serilog ILogEventSink so it works alongside UseSerilog(). +/// Register via: .WriteTo.Sink(new ErrorCountingLoggerSink()) +/// +public sealed class ErrorCountingLoggerSink : ILogEventSink +{ + private static IServiceScopeFactory? _scopeFactory; + + internal static readonly Counter ErrorCounter = + Metrics.CreateCounter( + "application_errors_total", + "Total application errors captured via Serilog", + new CounterConfiguration + { + LabelNames = ["level", "exception"] + }); + + public static void SetScopeFactory(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + public void Emit(LogEvent logEvent) + { + if (logEvent.Level < LogEventLevel.Error) return; + + string level = logEvent.Level.ToString().ToLowerInvariant(); + string exceptionType = logEvent.Exception?.GetType().Name ?? string.Empty; + ErrorCounter.WithLabels(level, exceptionType).Inc(); + + var scopeFactory = _scopeFactory; + + if (scopeFactory == null) + { + return; + } + + _ = Task.Run(async () => + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var exceptionLogs = scope.ServiceProvider.GetService(); + + if (exceptionLogs == null) + { + return; + } + + var frame = logEvent.Exception == null + ? null + : ExceptionNotificationHelpers.GetTopFrame(logEvent.Exception); + string? sourceFile = frame?.File == null + ? null + : ExceptionNotificationHelpers.NormalizeRepoPath(frame.Value.File); + + await exceptionLogs.CreateAsync(new CreateExceptionLogDto + { + UserId = AbpUserTenantAccessor.GetCurrentUserId(scope.ServiceProvider), + UserName = AbpUserTenantAccessor.GetCurrentUserName(scope.ServiceProvider), + TenantName = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(scope.ServiceProvider), + NotificationType = logEvent.Exception == null + ? ExceptionLogType.PrometheusErrorCounterEvent + : ExceptionLogType.PrometheusExceptionCounterEvent, + Channel = ExceptionLogChannel.Prometheus, + Severity = logEvent.Level >= LogEventLevel.Fatal + ? ExceptionLogSeverity.Critical + : ExceptionLogSeverity.Error, + Title = "Prometheus Error Counter Event", + Message = logEvent.RenderMessage(), + Source = nameof(ErrorCountingLoggerSink), + IsDeliveredRealtime = false, + ExceptionType = logEvent.Exception?.GetType().FullName, + ExceptionMessage = logEvent.Exception?.Message, + StackExcerpt = logEvent.Exception?.StackTrace, + SourceFile = sourceFile, + SourceLine = frame?.Line, + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }); + } + catch + { + // Swallow to avoid recursive logging from logger sink failures. + } + }); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs new file mode 100644 index 0000000000..fbbc56c8b7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Prometheus; +using Unity.GrantManager.Notifications; +using Volo.Abp.Uow; +using Unity.GrantManager.Notifications.Logs; +using Unity.GrantManager.Logs; + +namespace Unity.GrantManager.Web.Middleware; + +public class ExceptionCounterMiddleware( + RequestDelegate next, + ExceptionNotificationThrottle throttle, + ILogger logger) +{ + // Notify only in these environments; add "Staging" if desired + private static readonly HashSet NotifyEnvironments = + new(StringComparer.OrdinalIgnoreCase) + { + "Production", + "Test", + "Development" + }; + + // Internal so AbpExceptionNotificationSubscriber can also increment it for ABP-handled + // exceptions — the OpenShift "UnityHighExceptionRate" alert keys off this metric's "type" + // label, so both handled and unhandled exceptions need to feed it. + internal static readonly Counter ExceptionCounter = + Metrics.CreateCounter( + "application_exceptions_total", + "Total number of application exceptions", + new CounterConfiguration + { + LabelNames = new[] { "type" } + }); + + internal static readonly string CommitSha = ParseCommitSha( + typeof(ExceptionCounterMiddleware).Assembly + .GetCustomAttribute()? + .InformationalVersion); + + internal static readonly string CommitAuthor = + typeof(ExceptionCounterMiddleware).Assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == "CommitAuthor")?.Value ?? "unknown"; + + private static string ParseCommitSha(string? informationalVersion) + { + if (string.IsNullOrWhiteSpace(informationalVersion)) + { + return "unknown"; + } + + var plusIndex = informationalVersion.IndexOf('+'); + + return plusIndex >= 0 + ? informationalVersion[(plusIndex + 1)..] + : informationalVersion; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + ExceptionCounter.WithLabels(ex.GetType().Name).Inc(); + + ErrorCountingLoggerSink.ErrorCounter + .WithLabels("fatal", ex.GetType().Name) + .Inc(); + + QueueLogNotification(context, ex); + + throw; + } + } + + // Repo path and frame helpers are provided by ExceptionNotificationHelpers to avoid duplication + + private void QueueLogNotification(HttpContext context, Exception ex) + { + string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + string correlationId = context.TraceIdentifier; + + if (!NotifyEnvironments.Contains(env ?? string.Empty)) + { + return; + } + + if (!throttle.ShouldNotify(ex.GetType().Name)) + { + return; + } + + // Use the real root exception + ex = ex.GetBaseException(); + + // Capture values from the request context before it is disposed + string endpoint = $"{context.Request.Method} {context.Request.Path}"; + string exTypeName = ex.GetType().FullName ?? ex.GetType().Name; + string exMessage = ex.Message; + string innerMessage = ex.InnerException?.Message ?? string.Empty; + + // Compact stack trace with only application frames + string stackTrace = ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); + + // Resolve a scoped INotificationsAppService from a fresh DI scope so + // we can safely use it after the request scope has ended + var scopeFactory = context.RequestServices.GetRequiredService(); + + _ = Task.Run(async () => + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + + var uowManager = scope.ServiceProvider.GetRequiredService(); + var notifications = scope.ServiceProvider.GetRequiredService(); + var exceptionLogs = scope.ServiceProvider.GetService(); + + // Get current user and tenant name + var userId = AbpUserTenantAccessor.GetCurrentUserId(scope.ServiceProvider); + var userName = AbpUserTenantAccessor.GetCurrentUserName(scope.ServiceProvider) ?? "unknown"; + var tenantName = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(scope.ServiceProvider) ?? "unknown"; + + // Determine top frame (file/line) for initial facts so variables exist when creating the list + var topForFacts = ExceptionNotificationHelpers.GetTopFrame(ex); + string sourceFile = ExceptionNotificationHelpers.NormalizeRepoPath(topForFacts?.File ?? "(unknown)"); + int? sourceLine = topForFacts?.Line; + + var facts = ExceptionNotificationHelpers.BuildFacts( + exTypeName, + exMessage, + endpoint, + userName, + tenantName, + stackTrace, + sourceFile, + sourceLine, + CommitSha, + innerMessage); + + // Try to enrich with blame info similar to AbpExceptionNotificationSubscriber + GitHubBlameInfo? blame = null; + try + { + if (sourceLine.HasValue) + { + // Blame enrichment is best-effort here: don't let failures block notifications + string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); + var blameService = scope.ServiceProvider.GetService(); + if (blameService != null) + { + try + { + blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); + if (blame != null) + { + facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); + var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; + facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); + + if (blame.PullRequestUrl != null) + { + facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + { + facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); + } + } + } + } + catch (Exception blameEx) + { + logger.LogDebug(blameEx, "Blame lookup failed; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); + } + } + else + { + logger.LogDebug("Blame lookup service not registered; skipping blame enrichment for {File}:{Line}", sourceFile, sourceLine); + } + } + } + catch (Exception ex2) + { + // Catch-all: ensure notifications still send even if enrichment logic fails + logger.LogDebug(ex2, "Unexpected error during blame enrichment; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); + } + + // Provide simple activity title/subtitle for the notification + var activityTitle = $"{exTypeName} thrown at {endpoint}"; + var activitySubtitle = $"Environment: {env} | {endpoint} | {userName}@{tenantName}"; + + // Ensure a Unit-of-Work is active for any DB access inside NotificationsAppService + try + { + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + + // Try to post notification, but don't let failures prevent exception logging + try + { + await notifications.PostToNotificationsAsync( + activityTitle, + activitySubtitle, + facts); + } + catch (Exception notificationEx) + { + logger.LogWarning(notificationEx, "Failed to post Teams notification"); + } + + // Always attempt to log the exception, even if notification failed + if (exceptionLogs != null) + { + try + { + await exceptionLogs.CreateAsync(new CreateExceptionLogDto + { + UserId = userId, + UserName = userName, + TenantName = tenantName, + NotificationType = ExceptionLogType.MiddlewareUnhandledException, + Channel = ExceptionLogChannel.ExceptionPipeline, + Severity = ExceptionLogSeverity.Error, + Title = activityTitle, + Message = exMessage, + Source = nameof(ExceptionCounterMiddleware), + SourceReference = endpoint, + CorrelationId = correlationId, + IsDeliveredRealtime = false, + ExceptionType = exTypeName, + ExceptionMessage = exMessage, + StackExcerpt = stackTrace, + SourceFile = sourceFile, + SourceLine = sourceLine, + CommitSha = CommitSha, + Environment = env, + PayloadJson = null, + BlameAuthor = blame?.Author, + BlameEmail = blame?.Email, + BlameCommitSha = blame?.CommitSha, + BlameCommitMessage = blame?.Message, + PullRequestUrl = blame?.PullRequestUrl, + PullRequestNumber = blame?.PullRequestNumber, + PullRequestTitle = blame?.PullRequestTitle, + TicketReference = ExceptionNotificationHelpers.ExtractTicketReference(blame?.PullRequestTitle) + }); + } + catch (Exception logEx) + { + logger.LogWarning(logEx, "Failed to create exception log within UnitOfWork"); + } + } + + await uow.CompleteAsync(); + } + catch (Exception uowEx) + { + logger.LogWarning(uowEx, "Failed to complete UnitOfWork for exception handling"); + } + } + catch (Exception notifyEx) + { + logger.LogWarning( + notifyEx, + "Failed to send Teams exception notification"); + } + }); + } + + private static string BuildApplicationStackExcerpt(Exception ex) + { + return ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs new file mode 100644 index 0000000000..1133c4317f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs @@ -0,0 +1,180 @@ +using System; +using System.Diagnostics; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Unity.GrantManager.Notifications.Logs; + +namespace Unity.GrantManager.Web.Middleware +{ + internal static partial class ExceptionNotificationHelpers + { + // Matches Azure Boards work item references used in this repo's branch/PR naming, e.g. "AB#33086". + [GeneratedRegex(@"AB#\d+", RegexOptions.IgnoreCase)] + private static partial Regex TicketReferencePattern(); + + // Pulls the ticket reference (e.g. "AB#33086") out of a PR title so an exception log row + // can be traced back to the work item that introduced the failing line, not just the author. + public static string? ExtractTicketReference(string? pullRequestTitle) + { + if (string.IsNullOrWhiteSpace(pullRequestTitle)) + { + return null; + } + + var match = TicketReferencePattern().Match(pullRequestTitle); + return match.Success ? match.Value.ToUpperInvariant() : null; + } + public static string NormalizeRepoPath(string fullPath) + { + if (string.IsNullOrWhiteSpace(fullPath)) return fullPath; + + // Normalize separators for comparison + var path = fullPath.Replace("\\", "/"); + + // Prefer repository-relative path under applications/Unity.GrantManager/src/ + const string repoMarker = "applications/unity.grantmanager/src/"; + int idx = path.IndexOf(repoMarker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + return path[(idx + repoMarker.Length)..].TrimStart('/'); + } + + // Fallback to any src/ directory + const string srcMarker = "src/"; + idx = path.IndexOf(srcMarker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + return path[(idx + srcMarker.Length)..].TrimStart('/'); + } + + // Last resort: strip drive letter (Windows) and return the path relative to repository root if possible + // If we can't determine a repo-relative path, return just the file name so notifications remain readable + try + { + return System.IO.Path.GetFileName(path); + } + catch + { + return path; + } + } + + public static (string? File, int? Line)? GetTopFrame(Exception ex) + { + var trace = new StackTrace(ex, true); + + foreach (var frame in trace.GetFrames() ?? Array.Empty()) + { + var file = frame.GetFileName(); + var line = frame.GetFileLineNumber(); + + if (!string.IsNullOrWhiteSpace(file) && line > 0) + { + return (file, line); + } + } + + return null; + } + + public static string BuildBlamePath(string sourceFile) + { + if (string.IsNullOrWhiteSpace(sourceFile)) return sourceFile; + + // Normalize separators + var path = sourceFile.Replace("\\", "/").TrimStart('/'); + + // If caller already passed a repo-rooted path, return as-is + string result; + if (path.StartsWith("applications/", StringComparison.OrdinalIgnoreCase)) + result = path; + else if (path.StartsWith("src/", StringComparison.OrdinalIgnoreCase)) + result = $"applications/Unity.GrantManager/{path}"; + else + // Default: assume sourceFile is the portion after src/, so include src/ + result = $"applications/Unity.GrantManager/src/{path}"; + + return result; + } + + public static string BuildApplicationStackExcerpt(Exception ex) + { + var trace = new StackTrace(ex, true); + + var frames = trace.GetFrames(); + + if (frames == null || frames.Length == 0) + { + return "(no stack trace)"; + } + + // Keep only application frames + var appFrames = new System.Collections.Generic.List(); + + foreach (var f in frames) + { + var typeName = f.GetMethod()?.DeclaringType?.FullName; + + if (string.IsNullOrWhiteSpace(typeName)) + continue; + + if (typeName.StartsWith("Unity.", StringComparison.Ordinal)) + { + appFrames.Add(f); + if (appFrames.Count >= 5) + break; + } + } + + if (appFrames.Count == 0) + return ex.Message; + + var lines = new System.Collections.Generic.List(); + for (int i = 0; i < appFrames.Count; i++) + { + var f = appFrames[i]; + var method = f.GetMethod(); + var className = method?.DeclaringType?.Name ?? "UnknownClass"; + var methodName = method?.Name ?? "UnknownMethod"; + var file = f.GetFileName(); + var fileName = string.IsNullOrWhiteSpace(file) ? "unknown" : System.IO.Path.GetFileName(file); + var line = f.GetFileLineNumber(); + lines.Add($"{i + 1}. {className}.{methodName}() in {fileName}:{line}"); + } + + return string.Join(Environment.NewLine, lines); + } + + public static List BuildFacts( + string exTypeName, + string exMessage, + string endpoint, + string userName, + string tenantName, + string stackTrace, + string sourceFile, + int? sourceLine, + string releaseNumber, + string? innerMessage = null) + { + var facts = new List + { + new() { Name = "Exception", Value = exTypeName }, + new() { Name = "Message", Value = exMessage }, + new() { Name = "Endpoint", Value = endpoint }, + new() { Name = "User", Value = userName }, + new() { Name = "Tenant", Value = tenantName }, + new() { Name = "Stack Trace", Value = stackTrace }, + new() { Name = "Source", Value = sourceLine.HasValue ? $"{sourceFile}:{sourceLine}" : sourceFile }, + new() { Name = "Release Number", Value = releaseNumber }, + }; + + if (!string.IsNullOrEmpty(innerMessage)) + { + facts.Add(new Fact { Name = "Inner Exception", Value = innerMessage }); + } + + return facts; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs new file mode 100644 index 0000000000..c41dc6f91d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.Web.Middleware; + +/// +/// Singleton that tracks per-exception-type cooldowns and a global rate limit +/// to prevent Teams notification storms during an outage. +/// +public sealed class ExceptionNotificationThrottle +{ + // Only send one notification per exception type per cooldown window + private static readonly TimeSpan PerTypeCooldown = TimeSpan.FromMinutes(5); + + // Global cap: at most N notifications per rolling minute across all types + private const int GlobalMaxPerMinute = 5; + + // All state is accessed exclusively under _lock — no concurrent collections needed + private readonly object _lock = new(); + private readonly Dictionary _lastSent = new(); + private int _sentThisMinute; + private DateTimeOffset _windowStart = DateTimeOffset.UtcNow; + + /// + /// Returns true if a Teams notification should be sent for this exception type. + /// Thread-safe. + /// + public bool ShouldNotify(string exceptionTypeName) + { + var now = DateTimeOffset.UtcNow; + + lock (_lock) + { + // Reset the global window if a full minute has elapsed + if (now - _windowStart >= TimeSpan.FromMinutes(1)) + { + _sentThisMinute = 0; + _windowStart = now; + } + + // Per-type cooldown check — inside the lock to prevent concurrent + // callers with the same exception type both passing the check + if (_lastSent.TryGetValue(exceptionTypeName, out var last) && + now - last < PerTypeCooldown) + { + return false; + } + + if (_sentThisMinute >= GlobalMaxPerMinute) + { + return false; + } + + _sentThisMinute++; + _lastSent[exceptionTypeName] = now; + return true; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs new file mode 100644 index 0000000000..c6fac88fab --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -0,0 +1,239 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Integrations; + +namespace Unity.GrantManager.Web.Middleware; + +public class GitHubBlameLookupService : IBlameLookupService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly IEndpointManagementAppService? _endpointService; + + private readonly string _owner; + private readonly string _repo; + private readonly string _branch; + + public GitHubBlameLookupService( + HttpClient httpClient, + ILogger logger, + IEndpointManagementAppService? endpointService = null) + { + _httpClient = httpClient; + _logger = logger; + _endpointService = endpointService; + + string? repoUrl = null; + + if (_endpointService != null) + { + try + { + repoUrl = _endpointService.GetGitHubRepoUrlAsync() + .GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve GitHub repo URL from endpoint configuration; falling back to environment variables."); + } + } + + if (!string.IsNullOrWhiteSpace(repoUrl)) + { + var parts = repoUrl.TrimEnd('/').Split('/'); + _owner = parts.Length >= 2 ? parts[^2] : ""; + _repo = parts.Length >= 1 ? parts[^1] : ""; + } + else + { + _owner = Environment.GetEnvironmentVariable("GITHUB_OWNER") ?? ""; + _repo = Environment.GetEnvironmentVariable("GITHUB_REPO") ?? ""; + } + + var env = + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? "Production"; + + _branch = Environment.GetEnvironmentVariable("GITHUB_BRANCH") + ?? env switch + { + "Development" => "dev", + "Test" => "test", + _ => "main" + }; + + if( Environment.GetEnvironmentVariable("RabbitMQ__VirtualHost") == "dev2") { + _branch = "dev2"; + } + + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Unity-GrantManager"); + } + + public Task GetBlameFromReferenceAsync(string reference) + { + if (string.IsNullOrWhiteSpace(reference)) + return Task.FromResult(null); + + string branch = _branch; + string pathWithFragment = reference; + + int firstSlash = reference.IndexOf('/'); + + if (firstSlash > 0) + { + var possibleBranch = reference[..firstSlash]; + + if (possibleBranch is "main" or "dev" or "dev2" or "test") + { + branch = possibleBranch; + pathWithFragment = reference[(firstSlash + 1)..]; + } + } + + var parts = pathWithFragment.Split("#L"); + var path = parts[0]; + var line = (parts.Length > 1 && int.TryParse(parts[1], out var l)) ? l : 1; + + return GetBlameAsync(_owner, _repo, branch, path, line); + } + + public Task GetBlameAsync(string repoPath, int line) + => GetBlameAsync(_owner, _repo, _branch, repoPath, line); + + public async Task GetBlameAsync( + string owner, + string repo, + string branch, + string repoPath, + int line) + { + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) + return null; + + var query = BuildBlameQuery(owner, repo, branch, repoPath); + var payload = JsonSerializer.Serialize(new { query }); + + var url = await GetGraphQlUrlAsync(); + if (url == null) + return null; + + using var response = await _httpClient.PostAsync( + url, + new StringContent(payload, Encoding.UTF8, "application/json")); + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("errors", out var errors)) + { + _logger.LogWarning("[BlameLookup] GraphQL errors"); + return null; + } + + var ranges = root + .GetProperty("data") + .GetProperty("repository") + .GetProperty("object") + .GetProperty("blame") + .GetProperty("ranges"); + + foreach (var range in ranges.EnumerateArray()) + { + int start = range.GetProperty("startingLine").GetInt32(); + int end = range.GetProperty("endingLine").GetInt32(); + + if (line < start || line > end) + continue; + + var commit = range.GetProperty("commit"); + var author = commit.GetProperty("author"); + + var prs = commit + .GetProperty("associatedPullRequests") + .GetProperty("nodes"); + + string? prUrl = null; + string? prTitle = null; + int? prNumber = null; + + if (prs.GetArrayLength() > 0) + { + var pr = prs[0]; + prUrl = pr.GetProperty("url").GetString(); + prTitle = pr.GetProperty("title").GetString(); + + if (pr.TryGetProperty("number", out var n)) + prNumber = n.GetInt32(); + } + + return new GitHubBlameInfo + { + CommitSha = commit.GetProperty("oid").GetString() ?? "", + Author = author.GetProperty("name").GetString() ?? "", + Email = author.GetProperty("email").GetString() ?? "", + Message = commit.GetProperty("messageHeadline").GetString() ?? "", + PullRequestUrl = prUrl, + PullRequestNumber = prNumber, + PullRequestTitle = prTitle + }; + } + + return null; + } + + private async Task GetGraphQlUrlAsync() + { + try + { + return _endpointService != null + ? await _endpointService.GetGitHubGraphQlUrlAsync() + : "https://api.github.com/graphql"; + } + catch + { + return "https://api.github.com/graphql"; + } + } + + private static string BuildBlameQuery(string owner, string repo, string branch, string path) + { + return $@" +query {{ + repository(owner: ""{owner}"", name: ""{repo}"") {{ + object(expression: ""{branch}"") {{ + ... on Commit {{ + blame(path: ""{path}"") {{ + ranges {{ + startingLine + endingLine + commit {{ + oid + messageHeadline + author {{ + name + email + }} + associatedPullRequests(first: 1) {{ + nodes {{ + number + url + title + }} + }} + }} + }} + }} + }} + }} + }} +}}"; + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs new file mode 100644 index 0000000000..d1429c411e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace Unity.GrantManager.Web.Middleware; + +public record GitHubBlameInfo +{ + public string CommitSha { get; init; } = ""; + public string Author { get; init; } = ""; + public string Email { get; init; } = ""; + public string Message { get; init; } = ""; + public string? PullRequestUrl { get; init; } + public int? PullRequestNumber { get; init; } + public string? PullRequestTitle { get; init; } +} + +public interface IBlameLookupService +{ + Task GetBlameAsync(string repoPath, int line); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/LinkWorksheetsModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/LinkWorksheetsModal.js index 20ff4b3d40..29cfc66702 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/LinkWorksheetsModal.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/LinkWorksheetsModal.js @@ -40,57 +40,30 @@ if (!beingDragged.classList.contains('draggable-card')) return; - if (dragOver.classList.contains('single-target') - && event.target.childElementCount > 0) { - event.preventDefault(); + if (dragOver.classList.contains('single-target')) { + if (event.target.childElementCount > 0) { + event.preventDefault(); + } else { + dropToSingleTarget(event, null, 'published-form'); + } return; } - if (dragOver.classList.contains('single-target') - && event.target.childElementCount == 0) { - dropToSingleTarget(event, null, 'published-form'); - return; - } - - if (dragOver.classList.contains('multi-target') - && event.target.classList.contains('available-worksheets')) { - dropToAvailableWorksheets(event, 'published-form', null); - return; - } + if (!dragOver.classList.contains('multi-target')) return; - if (dragOver.classList.contains('multi-target') - && event.target.classList.contains('custom-tabs-list')) { - dropToCustomTabs(event, null, 'published-form'); - return; - } - - if (dragOver.classList.contains('multi-target') - && event.target.classList.contains('assessment-info-list')) { - dropToAssessmentInfo(event, null, 'published-form'); - return; - } - - if (dragOver.classList.contains('multi-target') - && event.target.classList.contains('project-info-list')) { - dropToProjectInfo(event, null, 'published-form'); - return; - } - - if (dragOver.classList.contains('multi-target') - && event.target.classList.contains('applicant-info-list')) { - dropToApplicantInfo(event, null, 'published-form'); - return; - } - - if (dragOver.classList.contains('multi-target') - && event.target.classList.contains('payment-info-list')) { - dropToPaymentInfo(event, null, 'published-form'); - return; - } + const multiTargetHandlers = [ + { className: 'available-worksheets', handler: dropToAvailableWorksheets, addClass: 'published-form', removeClass: null }, + { className: 'custom-tabs-list', handler: dropToCustomTabs, addClass: null, removeClass: 'published-form' }, + { className: 'assessment-info-list', handler: dropToAssessmentInfo, addClass: null, removeClass: 'published-form' }, + { className: 'project-info-list', handler: dropToProjectInfo, addClass: null, removeClass: 'published-form' }, + { className: 'applicant-info-list', handler: dropToApplicantInfo, addClass: null, removeClass: 'published-form' }, + { className: 'payment-info-list', handler: dropToPaymentInfo, addClass: null, removeClass: 'published-form' }, + { className: 'funding-agreement-info-list', handler: dropToFundingAgreementInfo, addClass: null, removeClass: 'published-form' } + ]; - if (dragOver.classList.contains('multi-target') - && event.target.classList.contains('funding-agreement-info-list')) { - dropToFundingAgreementInfo(event, null, 'published-form'); + const match = multiTargetHandlers.find(m => event.target.classList.contains(m.className)); + if (match) { + match.handler(event, match.addClass, match.removeClass); } }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index c27118c845..ca9ed8bfca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -1,10 +1,11 @@ @page @using Unity.GrantManager.ApplicationForms -@using Unity.Reporting.Permissions -@using Volo.Abp.AspNetCore.Mvc.UI.Layout; -@using Unity.GrantManager.Web.Pages.ApplicationForms; -@using Unity.GrantManager.Permissions; -@using Unity.Notifications.Permissions; +@using Unity.Reporting.Permissions +@using Volo.Abp.AspNetCore.Mvc.UI.Layout; +@using Unity.GrantManager.Web.Pages.ApplicationForms; +@using Unity.GrantManager.Permissions; +@using Unity.Notifications.Permissions; +@using Unity.AI.Permissions; @using Volo.Abp.Authorization.Permissions; @using Unity.GrantManager.Web.Views.Shared.Components.Notifications; @@ -23,11 +24,13 @@ } @section scripts { - - - - -} + + + + + + +} @section styles { @@ -101,15 +104,26 @@ - - + + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.Generate)) + { + + } + @@ -339,16 +353,16 @@
    - - - - - - - - + + + + + + + + -
    \ No newline at end of file +
    diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs index a1c12bf85a..19d14879ba 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs @@ -7,25 +7,26 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.Flex.Worksheets; -using Unity.GrantManager.ApplicationForms; -using Unity.GrantManager.Forms; -using Unity.GrantManager.Intakes; -using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; -using Volo.Abp.Features; -using Unity.Modules.Shared.Correlation; -using Unity.Flex.Worksheets.Definitions; -using Unity.AI.Settings; -using Unity.Flex; -using Volo.Abp.Settings; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Forms; +using Unity.GrantManager.Intakes; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; +using Volo.Abp.Features; +using Unity.Modules.Shared.Correlation; +using Unity.Flex.Worksheets.Definitions; +using Unity.AI.Settings; +using Unity.Flex; +using Volo.Abp.Settings; namespace Unity.GrantManager.Web.Pages.ApplicationForms { [Authorize] - public class MappingModel(IApplicationFormAppService applicationFormAppService, - IApplicationFormVersionAppService applicationFormVersionAppService, - IWorksheetAppService worksheetAppService, - IFeatureChecker featureChecker, - ISettingProvider settingProvider) : AbpPageModel + public class MappingModel(IApplicationFormAppService applicationFormAppService, + IApplicationFormVersionAppService applicationFormVersionAppService, + IApplicationFormVersionMappingReadService mappingReadService, + IFeatureChecker featureChecker, + ISettingProvider settingProvider) : AbpPageModel { [BindProperty(SupportsGet = true)] @@ -44,10 +45,13 @@ public class MappingModel(IApplicationFormAppService applicationFormAppService, public List? ApplicationFormVersionDtoList { get; set; } [BindProperty] - public string? ApplicationFormVersionDtoString { get; set; } - - [BindProperty] - public string? IntakeProperties { get; set; } + public string? ApplicationFormVersionDtoString { get; set; } + + [BindProperty] + public string? IntakeProperties { get; set; } + + [BindProperty] + public string? MappingSuggestionJson { get; set; } [BindProperty] public bool FlexEnabled { get; set; } @@ -100,80 +104,33 @@ public async Task OnGetAsync() IntakeProperties = JsonSerializer.Serialize(await GenerateMappingFieldsAsync()); } - private async Task> GenerateMappingFieldsAsync() - { - IntakeMapping intakeMapping = new(); - List properties = []; - - foreach (var property in intakeMapping.GetType().GetProperties()) - { - var browsable = property.GetCustomAttributes(typeof(BrowsableAttribute), true).Cast().SingleOrDefault(); - var displayName = property.GetCustomAttributes(typeof(DisplayNameAttribute), true).Cast().SingleOrDefault(); - var fieldType = property.GetCustomAttributes(typeof(MapFieldTypeAttribute), true).Cast().SingleOrDefault(); - - if (browsable != null && browsable.IsDefaultAttribute()) - { - properties.Add(new MapField() - { - Name = property.Name, - Type = fieldType?.Type ?? "String", - IsCustom = false, - Label = displayName?.DisplayName ?? property.Name - }); - } - } - - if (await featureChecker.IsEnabledAsync("Unity.Flex")) - { - // Get the available field from the worksheets for the current Form - var formVersion = await applicationFormVersionAppService.GetByChefsFormVersionId(ChefsFormVersionGuid); - var worksheets = await worksheetAppService.GetListByCorrelationAsync(formVersion?.Id ?? Guid.Empty, CorrelationConsts.FormVersion); - - foreach (var worksheet in worksheets) - { - // Get worksheet name - var fields = worksheet - .Sections - .SelectMany(f => f.Fields) - .ToList(); - - properties.AddRange(from CustomFieldDto? field in fields - where field.IsMappable() - select new MapField() - { - Name = $"{field.Name}.{field.Type}", - Type = ConvertCustomType(field.Type), - IsCustom = true, - Label = $"{field.Label} ({worksheet.Name})" - }); - } - } - - return [.. properties.OrderBy(s => s.Label)]; - } - - private static string ConvertCustomType(CustomFieldType type) - { - return type switch - { - CustomFieldType.Text => "String", - CustomFieldType.Date => "Date", - CustomFieldType.Email => "Email", - CustomFieldType.Phone => "Phone", - CustomFieldType.DateTime => "Date", - CustomFieldType.YesNo => "YesNo", - CustomFieldType.Currency => "Currency", - CustomFieldType.Numeric => "Number", - CustomFieldType.Radio => "Radio", - CustomFieldType.Checkbox => "Checkbox", - CustomFieldType.CheckboxGroup => "CheckboxGroup", - CustomFieldType.SelectList => "SelectList", - CustomFieldType.BCAddress => "BCAddress", - CustomFieldType.TextArea => "TextArea", - CustomFieldType.DataGrid => "DataGrid", - _ => "", - }; - } + private async Task> GenerateMappingFieldsAsync() + { + if (ApplicationFormVersionDto?.Id is not Guid formVersionId || formVersionId == Guid.Empty) + { + return []; + } + + var readModel = await mappingReadService.GetAsync(formVersionId); + var properties = readModel.ChefsFields + .Select(field => new MapField + { + Name = field.Name, + Type = field.Type, + IsCustom = field.IsCustom, + Label = field.Label + }) + .Concat(readModel.Worksheets.SelectMany(worksheet => worksheet.Fields).Select(field => new MapField + { + Name = field.Name, + Type = field.Type, + IsCustom = field.IsCustom, + Label = field.Label + })) + .ToList(); + + return [.. properties.OrderBy(s => s.Label)]; + } public class MapField { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css index f6d49826d5..2c90349c2b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css @@ -129,14 +129,24 @@ tr:nth-child(even) {background-color: #f2f2f2;} position: relative; } -.buttons { - width: fit-content; - display: block; -} - -.buttons-div { - display: inline-flex; - padding: 20px; +.buttons { + width: fit-content; + display: block; +} + +.ai-generate-btn:disabled, +.ai-generate-btn.disabled, +.ai-generate-btn[data-ai-shared-generating='1'], +.ai-generate-btn[data-ai-cooldown-active='1'], +.ai-generate-btn[data-ai-cooldown-checking='1'] { + cursor: not-allowed; + opacity: 0.55; + pointer-events: none; +} + +.buttons-div { + display: inline-flex; + padding: 20px; margin: auto; } 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 6798dc0caf..c01d621bbd 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 @@ -53,16 +53,18 @@ 'bcaddress', 'datagrid']); - const UIElements = { - btnBack: $('#btn-back'), - btnSave: $('#btn-save'), - btnEdit: $('#btn-edit'), - btnSync: $('#btn-sync'), - btnReset: $('#btn-reset'), - btnClose: $('.btn-close'), - btnSaveMapping: $('#btn-save-mapping'), - btnCancel: $('#btn-cancel-mapping'), - inputSearchBar: $('#search-bar'), + const UIElements = { + btnBack: $('#btn-back'), + btnSave: $('#btn-save'), + btnEdit: $('#btn-edit'), + btnGenerate: $('#btn-generate'), + btnGenerateWorksheet: $('#btn-generate-worksheet'), + btnSync: $('#btn-sync'), + btnReset: $('#btn-reset'), + btnClose: $('.btn-close'), + btnSaveMapping: $('#btn-save-mapping'), + btnCancel: $('#btn-cancel-mapping'), + inputSearchBar: $('#search-bar'), selectVersionList: $('#applicationFormVersion'), editMappingModal: $('#editMappingModal'), uiConfigurationTab: $('#nav-ui-configuration'), @@ -93,15 +95,17 @@ function bindUIEvents() { UIElements.btnBack.on('click', handleBack); - UIElements.btnSave.on('click', handleSave); - UIElements.btnSaveMapping.on('click', handleSaveEditMapping); - UIElements.btnSync.on('click', handleSync); - UIElements.btnEdit.on('click', handleEdit); - UIElements.btnReset.on('click', handleReset); - UIElements.btnCancel.on('click', handleCancelMapping); - UIElements.btnClose.on('click', handleCancelMapping); - UIElements.inputSearchBar.on('keyup', handleSeearchBar); - UIElements.selectVersionList.on('change', handleSelectVersion); + UIElements.btnSave.on('click', handleSave); + UIElements.btnSaveMapping.on('click', handleSaveEditMapping); + UIElements.btnSync.on('click', handleSync); + UIElements.btnEdit.on('click', handleEdit); + UIElements.btnGenerate.on('click', queueFormMapping); + UIElements.btnGenerateWorksheet.on('click', queueFormWorksheet); + UIElements.btnReset.on('click', handleReset); + UIElements.btnCancel.on('click', handleCancelMapping); + UIElements.btnClose.on('click', handleCancelMapping); + UIElements.inputSearchBar.on('keyup', handleSeearchBar); + UIElements.selectVersionList.on('change', handleSelectVersion); UIElements.mappingTab.on('click', handleMappingTabClick); // Persist active tab to localStorage on switch @@ -146,15 +150,223 @@ }); } - function handleEdit() { - $('#jsonText').val(prettyJson(existingMappingString)); - UIElements.editMappingModal.addClass('display-modal'); - } - - function handleSaveEditMapping() { - try { - let jsonText = $('#jsonText').val(); - $.parseJSON(jsonText); + function handleEdit() { + $('#jsonText').val(prettyJson(existingMappingString)); + UIElements.editMappingModal.addClass('display-modal'); + } + + function queueFormMapping(triggerButton = null) { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); + if (!validateGuid(formVersion)) { + abp.notify.error('', 'The Form Version ID is not in a GUID format'); + return; + } + if (!validateGuid(applicationId)) { + abp.notify.error('', 'The Application ID is not in a GUID format'); + return; + } + + const buttonElement = triggerButton?.currentTarget || triggerButton?.target || triggerButton || UIElements.btnGenerate?.get?.(0); + const $button = $(buttonElement); + const existingHtml = $button.html(); + + if ($button.prop('disabled')) { + return; + } + + globalThis.AIGenerationButtonState?.setGenerating($button); + + abp.ajax({ + url: `/api/app/ai/generation/form-mapping?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, + type: 'POST', + }) + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + + if (status === 'Completed') { + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); + refreshMappingAfterGeneration(applicationId, formVersion); + return; + } + + monitorFormMappingGeneration(applicationId, $button, existingHtml); + }) + .fail(function (error) { + if (globalThis.AIGenerationButtonState?.handleQueueFailure(error)) { + return; + } + + abp.message.error('Failed to queue AI mapping generation. Please try again.'); + restoreGenerateMappingButton($button, existingHtml); + globalThis.syncAIRateLimitButtons?.(); + }); + } + + function queueFormWorksheet(triggerButton = null) { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); + if (!validateGuid(formVersion) || !validateGuid(applicationId)) { + abp.notify.error('', 'The Form Version ID or Application ID is not in a GUID format'); + return; + } + + const buttonElement = triggerButton?.currentTarget || triggerButton?.target || triggerButton || UIElements.btnGenerateWorksheet?.get?.(0); + const $button = $(buttonElement); + const existingHtml = $button.html(); + + if ($button.prop('disabled')) { + return; + } + + globalThis.AIGenerationButtonState?.setGenerating($button); + + abp.ajax({ + url: `/api/app/ai/generation/form-worksheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, + type: 'POST', + }) + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + if (status === 'Completed') { + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); + refreshWorksheetAfterGeneration(); + return; + } + + monitorFormWorksheetGeneration(applicationId, $button, existingHtml); + }) + .fail(function (error) { + if (globalThis.AIGenerationButtonState?.handleQueueFailure(error)) { + return; + } + + abp.message.error('Failed to queue AI worksheet generation. Please try again.'); + restoreGenerateWorksheetButton($button, existingHtml); + globalThis.syncAIRateLimitButtons?.(); + }); + } + + function monitorFormWorksheetGeneration(applicationId, $button, existingHtml) { + globalThis.AIGenerationButtonState?.monitor({ + $button, + originalHtml: existingHtml, + getStatus: () => abp.ajax({ + url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-worksheet`, + type: 'GET' + }), + onComplete: function () { + refreshWorksheetAfterGeneration(); + }, + onFailed: function (request) { + abp.message.error(request?.failureReason || 'AI worksheet generation failed.'); + }, + onPollFailed: function () { + abp.message.error('Unable to load AI worksheet generation status. Please try again.'); + } + }); + } + + function refreshWorksheetAfterGeneration() { + abp.notify.success('', 'Worksheet generated and assigned successfully. Reloading page.'); + setTimeout(function () { + globalThis.location.reload(); + }, 500); + } + + function monitorFormMappingGeneration(applicationId, $button, existingHtml) { + globalThis.AIGenerationButtonState?.monitor({ + $button, + originalHtml: existingHtml, + getStatus: () => abp.ajax({ + url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-mapping`, + type: 'GET' + }), + onComplete: function () { + refreshMappingAfterGeneration(applicationId); + }, + onFailed: function (request) { + abp.message.error(request?.failureReason || 'AI mapping generation failed.'); + }, + onPollFailed: function () { + abp.message.error('Unable to load AI mapping generation status. Please try again.'); + } + }); + } + + function refreshMappingAfterGeneration(applicationId, formVersion = null) { + const resolvedFormVersion = String(formVersion ?? document.getElementById('formVersionId')?.value ?? '').trim(); + if (!validateGuid(resolvedFormVersion)) { + abp.notify.error('', 'Unable to refresh the generated mapping because the Form Version ID is invalid.'); + return; + } + + abp.ajax({ + url: `/api/app/application-form-version/${encodeURIComponent(resolvedFormVersion)}`, + type: 'GET' + }) + .done(function (applicationFormVersionDto) { + const availableChefsFields = applicationFormVersionDto?.availableChefsFields + ? JSON.parse(applicationFormVersionDto.availableChefsFields) + : []; + + $('#applicationFormVersionDtoString').val(JSON.stringify(applicationFormVersionDto ?? {})); + $('#availableChefsFields').val(applicationFormVersionDto?.availableChefsFields ?? ''); + $('#existingMapping').val(applicationFormVersionDto?.submissionHeaderMapping ?? ''); + + existingMappingString = applicationFormVersionDto?.submissionHeaderMapping ?? ''; + availableChefFieldsString = applicationFormVersionDto?.availableChefsFields ?? ''; + + $(intakeMapColumn).empty(); + $(worksheetMapColumn).empty(); + dataTable.clear().draw(); + initializeIntakeMap(availableChefsFields); + bindExistingMaps(); + + abp.notify.success('', 'Form mapping generated and saved successfully.'); + }) + .fail(function () { + abp.notify.error('', 'Form mapping generated, but the page could not refresh the saved mapping.'); + }); + } + + function restoreGenerateMappingButton($button, existingHtml) { + if (!$button?.length) { + return; + } + + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); + $button.find('span').last().text('Generate Mapping'); + } + + function restoreGenerateWorksheetButton($button, existingHtml) { + if (!$button?.length) { + return; + } + + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); + $button.find('span').last().text('Generate Worksheet'); + } + + function restoreGenerateScoresheetButton($button, existingHtml) { + if (!$button?.length) { + return; + } + + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); + $button.find('span').last().text('Generate Scoresheet'); + } + + function handleSaveEditMapping() { + try { + let jsonText = $('#jsonText').val(); + $.parseJSON(jsonText); let mappingJsonStr = jsonText.replace(/\s+/g, ' ').replace(/(\r\n|\n|\r)/gm, ""); UIElements.btnSaveMapping.prop('disabled', true); handleSaveMapping($.parseJSON(mappingJsonStr)); @@ -176,12 +388,12 @@ '', 'The JSON is not valid:' + err ); - } - } - - function handleCancelMapping() { - UIElements.editMappingModal.removeClass('display-modal'); - } + } + } + + function handleCancelMapping() { + UIElements.editMappingModal.removeClass('display-modal'); + } function handleSeearchBar(e) { let filterValue = e.currentTarget.value; @@ -355,48 +567,54 @@ } + function createIntakeFieldCard(intakeField) { + let intakeFieldJson = intakeField; + let dragableDiv = document.createElement('div'); + dragableDiv.id = 'unity_' + intakeFieldJson.Name; + dragableDiv.className = 'card mapping-field'; + dragableDiv.setAttribute("draggable", "true"); + + // Set icon HTML (internal code, safe) + dragableDiv.innerHTML = `${setTypeIndicator(intakeField)}`; + + // Append label as text node to prevent HTML injection + dragableDiv.appendChild(document.createTextNode(intakeFieldJson.Label)); + + // Append asterisk and route to the appropriate column based on custom status + if (intakeFieldJson.IsCustom) { + dragableDiv.appendChild(document.createTextNode(" *")); + dragableDiv.className += ' custom-field'; + worksheetMapColumn.appendChild(dragableDiv); + } else { + intakeMapColumn.appendChild(dragableDiv); + } + } + + function buildAvailableChefsFieldsRows(availableChefsFields) { + let rowsToAdd = []; + for (let key of Object.keys(availableChefsFields)) { + let jsonObj = JSON.parse(availableChefsFields[key]); + if (allowableTypes.has(jsonObj.type.trim())) { + rowsToAdd.push([stripHtml(jsonObj.label), key, jsonObj.type, key]); + } + } + return rowsToAdd; + } + function initializeIntakeMap(availableChefsFields) { try { let intakeFields = JSON.parse(intakeFieldsString); for (let intakeField of intakeFields) { - let intakeFieldJson = intakeField; - if (!excludedIntakeMappings.has(intakeFieldJson.Name)) { - let dragableDiv = document.createElement('div'); - dragableDiv.id = 'unity_' + intakeFieldJson.Name; - dragableDiv.className = 'card mapping-field'; - dragableDiv.setAttribute("draggable", "true"); - - // Set icon HTML (internal code, safe) - dragableDiv.innerHTML = `${setTypeIndicator(intakeField)}`; - - // Append label as text node to prevent HTML injection - dragableDiv.appendChild(document.createTextNode(intakeFieldJson.Label)); - - // Append asterisk if custom - if (intakeFieldJson.IsCustom) { - dragableDiv.appendChild(document.createTextNode(" *")); - } - if (intakeFieldJson.IsCustom) { - worksheetMapColumn.appendChild(dragableDiv); - dragableDiv.className += ' custom-field'; - } else { - intakeMapColumn.appendChild(dragableDiv); - } + if (!excludedIntakeMappings.has(intakeField.Name)) { + createIntakeFieldCard(intakeField); } } - let keys = Object.keys(availableChefsFields); dataTable.clear(); - let rowsToAdd = []; - for (let key of keys) { - let jsonObj = JSON.parse(availableChefsFields[key]); - if (allowableTypes.has(jsonObj.type.trim())) { - rowsToAdd.push([stripHtml(jsonObj.label), key, jsonObj.type, key]); - } - } + let rowsToAdd = buildAvailableChefsFieldsRows(availableChefsFields); if (rowsToAdd.length > 0) { dataTable.rows.add(rowsToAdd); @@ -571,4 +789,4 @@ function dragEnd(ev) { if (draggedEl.classList + "" !== "undefined") { draggedEl.classList.remove('dragging'); } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinks.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinks.js index 73a5ad2eda..1ecdc59e1d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinks.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinks.js @@ -53,8 +53,6 @@ $(function () { this.arr.push(linkText); - let linkInput = this; - let link = document.createElement('span'); link.className = this.options.linkClass + ' ' + linkClass; link.innerText = linkText; @@ -63,13 +61,13 @@ $(function () { closeIcon.innerHTML = '×'; // delete the link when icon is clicked - closeIcon.addEventListener('click', function (e) { + closeIcon.addEventListener('click', (e) => { e.preventDefault(); - let link = this.parentNode; + let linkNode = closeIcon.parentNode; - for (let i = 0; i < linkInput.wrapper.childNodes.length; i++) { - if (linkInput.wrapper.childNodes[i] == link) - linkInput.deleteLink(link, i); + for (let i = 0; i < this.wrapper.childNodes.length; i++) { + if (this.wrapper.childNodes[i] == linkNode) + this.deleteLink(linkNode, i); } }) @@ -100,10 +98,8 @@ $(function () { // Add links programmatically LinksInput.prototype.addData = function (array) { - let plugin = this; - - array.forEach(function (string) { - plugin.addLink(string); + array.forEach((string) => { + this.addLink(string); }) return this; } @@ -122,14 +118,13 @@ $(function () { this.orignal_input.removeAttribute('hidden'); delete this.orignal_input; - let self = this; - Object.keys(this).forEach(function (key) { - if (self[key] instanceof HTMLElement) - self[key].remove(); + Object.keys(this).forEach((key) => { + if (this[key] instanceof HTMLElement) + this[key].remove(); if (key != 'options') - delete self[key]; + delete this[key]; }); this.initialized = false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AssigneeSelection/AssigneeSelection.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AssigneeSelection/AssigneeSelection.js index 0a2044c100..cb41bf73cc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AssigneeSelection/AssigneeSelection.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AssigneeSelection/AssigneeSelection.js @@ -57,8 +57,6 @@ $(function () { // Push the tag and duty to this.arr this.arr.push({ Id: Id, Duty: dutyText }); - let tagInput = this; - let tag = document.createElement('dev'); tag.className = this.options.tagClass + ' ' + tagClass; @@ -72,11 +70,11 @@ $(function () { dutyInput.placeholder = 'Add their duties'; dutyInput.value = dutyText; dutyInput.classList.add('user-tags-duty-input'); - dutyInput.addEventListener('blur', function () { + dutyInput.addEventListener('blur', () => { // Update duty value in this.arr when input field loses focus - let index = Array.from(tagInput.wrapper.childNodes).indexOf(tag); - tagInput.arr[index].Duty = dutyInput.value.trim(); - tagInput.orignal_input.value = JSON.stringify(tagInput.arr); + let index = Array.from(this.wrapper.childNodes).indexOf(tag); + this.arr[index].Duty = dutyInput.value.trim(); + this.orignal_input.value = JSON.stringify(this.arr); }); let lineBreak = document.createElement('br'); innerDiv.appendChild(label); @@ -91,13 +89,13 @@ $(function () { closeIcon.classList.add('user-tags-close'); // delete the tag when icon is clicked - closeIcon.addEventListener('click', function (e) { + closeIcon.addEventListener('click', (e) => { e.preventDefault(); - let tag = this.parentNode.parentNode; + let tagNode = closeIcon.parentNode.parentNode; - for (let i = 0; i < tagInput.wrapper.childNodes.length; i++) { - if (tagInput.wrapper.childNodes[i] == tag) - tagInput.deleteTag(tag, i); + for (let i = 0; i < this.wrapper.childNodes.length; i++) { + if (this.wrapper.childNodes[i] == tagNode) + this.deleteTag(tagNode, i); } }) @@ -113,15 +111,14 @@ $(function () { // Delete Tags UserTagsInput.prototype.deleteTag = function (tag, i) { - let self = this; if (this.arr[i] == 'Uncommon Tags') { abp.message.confirm('Are you sure to delete all the uncommon tags?') - .then(function (confirmed) { + .then((confirmed) => { if (confirmed) { tag.remove(); - self.arr.splice(i, 1); - self.orignal_input.value = JSON.stringify(self.arr); - return self; + this.arr.splice(i, 1); + this.orignal_input.value = JSON.stringify(this.arr); + return this; } }); @@ -152,10 +149,8 @@ $(function () { // Add tags programmatically UserTagsInput.prototype.addData = function (array) { - let plugin = this; - - array.forEach(function (string) { - plugin.addTag(string); + array.forEach((string) => { + this.addTag(string); }) return this; } @@ -174,14 +169,13 @@ $(function () { this.orignal_input.removeAttribute('hidden'); delete this.orignal_input; - let self = this; - Object.keys(this).forEach(function (key) { - if (self[key] instanceof HTMLElement) - self[key].remove(); + Object.keys(this).forEach((key) => { + if (this[key] instanceof HTMLElement) + this[key].remove(); if (key != 'options') - delete self[key]; + delete this[key]; }); this.initialized = false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml index 93cc895573..d94f3f4ba3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml @@ -25,10 +25,6 @@ { } - @if (Model.ShowProgramDetails) - { - - } } @section scripts { @@ -50,11 +46,6 @@ { } - @if (Model.ShowProgramDetails) - { - - - } }
    @@ -97,12 +88,6 @@ AI } - @if (Model.ShowProgramDetails) - { - - }
    @@ -122,6 +107,10 @@
    +
    +

    Payments

    +
    +
    diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.cshtml new file mode 100644 index 0000000000..30e975632f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.cshtml @@ -0,0 +1,70 @@ +@page +@using Unity.GrantManager.Logs +@model Unity.GrantManager.Web.Pages.ExceptionLogs.IndexModel +@{ + ViewBag.PageTitle = "Exception Logs"; +} + +@section styles { + +} + +@section scripts { + +} + +
    +
    +
    +
    +

    Exception Logs

    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    + +
    +
    +
    + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.cshtml.cs new file mode 100644 index 0000000000..215a30e7e4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; + +namespace Unity.GrantManager.Web.Pages.ExceptionLogs +{ + [Authorize(IdentityConsts.ITOperationsPolicyName)] + public class IndexModel : GrantManagerPageModel + { + public void OnGet() + { + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.css new file mode 100644 index 0000000000..3a9d7d22b9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.css @@ -0,0 +1,24 @@ +.exception-logs-action-bar .exception-logs-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + column-gap: 10px; +} + +.exception-logs-action-bar .exception-logs-filter-field { + display: flex; + flex-direction: column; +} + +#ExceptionLogsTable td.log-title { + max-width: 270px; +} + +#ExceptionLogsTable tbody tr.has-details { + cursor: pointer; +} + +#ExceptionLogsTable .exception-log-details pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.js new file mode 100644 index 0000000000..aa0c4bc6d6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ExceptionLogs/Index.js @@ -0,0 +1,214 @@ +$(function () { + let dt = $('#ExceptionLogsTable'); + + const UIElements = { + fromDate: $('#FromDate'), + toDate: $('#ToDate'), + severity: $('#Severity') + }; + + const listColumns = getColumns(); + + const exceptionLogsTable = initializeDataTable({ + dt, + listColumns, + defaultVisibleColumns: [], + defaultSortColumn: { name: 'creationTime', dir: 'desc' }, + dataEndpoint: unity.grantManager.logs.exceptionLog.getList, + data: function () { + return { + fromDate: UIElements.fromDate.val(), + toDate: UIElements.toDate.val(), + severity: UIElements.severity.val() + }; + }, + actionButtons: commonTableActionButtons('Exception Logs'), + serverSideEnabled: true, + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dynamicButtonContainerId: 'dynamicButtonContainerId', + externalSearchId: 'search-exception-logs', + fixedHeaders: true + }); + + UIElements.fromDate.on('change', reloadTable); + UIElements.toDate.on('change', reloadTable); + UIElements.severity.on('change', reloadTable); + + function reloadTable() { + exceptionLogsTable.ajax.reload(); + } + + // Marks rows that have expandable exception details, so only those get the pointer cursor. + exceptionLogsTable.on('draw', function () { + exceptionLogsTable.rows().every(function () { + $(this.node()).toggleClass('has-details', hasDetails(this.data())); + }); + }); + + // Row click toggles the exception details child row (instead of a
    element). + exceptionLogsTable.on('click', 'tbody tr', function (e) { + if ($(e.target).closest('a').length) { + return; + } + + const row = exceptionLogsTable.row(this); + const rowData = row.data(); + if (!rowData || !hasDetails(rowData)) { + return; + } + + if (row.child.isShown()) { + row.child.hide(); + } else { + row.child(buildExceptionDetails(rowData), 'exception-log-details-row').show(); + } + }); +}); + +// ============================ Columns ============================ + +function getColumns() { + let columnIndex = 0; + return [ + getCreatedColumn(columnIndex++), + getTextColumn(columnIndex++, 'Severity', 'severity', { minWidth: true }), + getTextColumn(columnIndex++, 'Type', 'notificationType', { minWidth: true }), + getTitleColumn(columnIndex++), + getTextColumn(columnIndex++, 'Source', 'source'), + getTextColumn(columnIndex++, 'Count', 'occurrenceCount'), + getFallbackTextColumn(columnIndex++, 'User', 'userName', '(none)'), + getFallbackTextColumn(columnIndex++, 'Tenant', 'tenantName', '(host)'), + getTextColumn(columnIndex++, 'Author', 'blameAuthor'), + getTextColumn(columnIndex++, 'Ticket', 'ticketReference'), + getPrColumn(columnIndex++) + ]; +} + +function getCreatedColumn(columnIndex) { + return { + title: 'Created', + name: 'creationTime', + data: 'creationTime', + className: 'data-table-header text-nowrap', + index: columnIndex, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + }; +} + +// Title text comes straight from the data binding; the tooltip (full message) is set as a +// plain attribute on the cell rather than built as an HTML string. +function getTitleColumn(columnIndex) { + return { + title: 'Title', + name: 'title', + data: 'title', + className: 'data-table-header log-title', + index: columnIndex, + createdCell: function (cell, cellData, rowData) { + if (rowData.message) { + $(cell).attr('title', rowData.message); + } + } + }; +} + +// Markup for the link lives in the #exception-log-pr-link-template