diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md index 8b8537d6b0..9baa5e28ea 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md @@ -3,7 +3,9 @@ ## Architecture - [`index.md`](./index.md) - [`flow-map.md`](./flow-map.md) +- [`operation-pipeline.md`](./operation-pipeline.md) - [`prompt-map.md`](./prompt-map.md) +- [`configuration.md`](./configuration.md) - [`implementation-playbook.md`](./implementation-playbook.md) ## Operations @@ -12,3 +14,4 @@ - [`operations/application-scoring.md`](./operations/application-scoring.md) - [`operations/form-mapping.md`](./operations/form-mapping.md) - [`operations/form-worksheet.md`](./operations/form-worksheet.md) +- [`operations/form-scoresheet.md`](./operations/form-scoresheet.md) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/configuration.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/configuration.md new file mode 100644 index 0000000000..aecbcd34e9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/configuration.md @@ -0,0 +1,35 @@ +# Runtime Configuration + +AI behavior is split between database-owned configuration and deployment +configuration. The database is the source of truth for which model, operation, and +prompt are used; appsettings holds deployment connectivity and operational settings. + +## Database configuration + +| Record | Owns | +| --- | --- | +| `AIModel` | Provider, deployment name (`Name`), active state, and model settings JSON | +| `AIOperation` | Prompt family (`Name`), selected model, execution mode, completion-token limit, and active state | +| `AIPrompt` | Versioned system/user templates, metadata, active state, and optional tenant ownership | + +Host seeders create the built-in models, operations, and global prompts. Operations +select models by ID; `AIModel.Name` is the provider deployment identifier. The runtime +rejects inactive or unsupported configuration rather than choosing a fallback model. + +## Prompt selection + +For a prompt family, host requests use the newest active global prompt. Tenant requests +use the newest active tenant prompt, then fall back to the newest active global prompt. +Operations do not store a prompt ID or version. + +## External configuration + +Provider endpoint, API key, and authenticated-user cooldown remain deployment configuration: + +```text +Azure:OpenAI:Endpoint +Azure:OpenAI:ApiKey +Azure:Generation:CooldownSeconds +``` + +Do not add operation defaults, profile maps, or prompt versions to appsettings. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md index 42bd018836..0401643211 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md @@ -1,14 +1,21 @@ # Flow Map -## Standard path -UI -> API app service -> queue -> background job -> AI runtime -> persisted result +```text +UI -> AIGenerationAppService -> IApplicationGenerationQueue +automation -------------------> IApplicationGenerationQueue +IApplicationGenerationQueue -> AIGenerationRequest + background job + -> operation executor + -> Unity.AI runtime + -> operation-specific 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 +The app service authorizes and feature-gates UI requests. Automatic intake checks its +own tenant, form, and feature preconditions before entering the queue. The Grant Manager +queue resolves the active database operation, prevents duplicate active requests, +validates prerequisites, and enqueues work. The background job establishes tenant scope +and records request state; its executor owns operation-specific input and persistence. +The runtime resolves the prompt and model configuration, renders the request, calls the +provider, and parses the response. -## Build Rule -See [`implementation-playbook.md`](./implementation-playbook.md) for the canonical add-a-new-operation sequence. +The form mapping, worksheet, and scoresheet operations require an application form +version. See [operation pipeline](./operation-pipeline.md) for ownership rules. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md index 9a9a50a207..7c11f6f702 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md @@ -10,64 +10,28 @@ Use these existing operations as the canonical references: 3. `AttachmentSummary` 4. `FormMapping` 5. `FormWorksheet` +6. `FormScoresheet` ## 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 +1. Add the catalog definition, prompt family, model/operation seed, feature, and permissions. +2. Add supported prompt versions; do not assume a specific version number. +3. Add the runtime request/response contract and implementation. +4. Add an executor when Grant Manager must load input or persist a result. +5. Register the executor through the existing transient DI convention. +6. Expose a generate surface and UI only when users need one. +7. Add focused catalog, runtime, executor, and persistence tests. ## Rules -- Keep the prompt as the source of truth. -- Reuse the existing async generation pattern. +- Keep prompt content and operation/model configuration in the database. +- Reuse the shared generation pipeline. - 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. +- Keep operation-specific input and persistence in the executor. +- Do not add UI write-back behavior unless the operation persists its output. ## Validation -- Confirm the prompt version is v2. -- Confirm the operation exists in the AI operation seed. +- Confirm the operation exists in the catalog and host seed. +- Confirm every supported prompt version resolves correctly. - 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 index bc108e3310..bc5832aab7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md @@ -1,80 +1,37 @@ -# Unity.AI Index +# Unity.AI -## Domain.Shared -AI constants: -- feature flags -- permission names -- localization keys -- prompt type names +`Unity.AI` owns provider-neutral AI contracts, prompt/model/operation configuration, +runtime execution, and the generation API. Grant Manager owns the application data, +queue implementation, operation executors, and persistence of generated results. -## Application.Contracts -Public AI surface: -- app service interfaces -- queue interfaces -- DTOs -- permission definitions +## Boundaries -## Application -AI implementation: -- runtime -- prompt seeding -- generation app services -- validators -- prompt logging +| Area | Responsibility | +| --- | --- | +| `Domain.Shared` | Features, permissions, localization, and prompt family names | +| `Application.Contracts` | Runtime, generation, queue, and DTO contracts | +| `Application` | Prompt/model/operation seeds, provider runtime, API, and status reads | +| `Runtime/Execution` | Prompt rendering, provider calls, response parsing, and prompt logging | +| Grant Manager | Request locking, background jobs, operation executors, and result persistence | +| `Web` | Menus, generate actions, and status polling | -## Web -UI-facing AI bits: -- menus -- generation buttons -- status polling +## Operation catalog -## Files -### Application -- `Operations` - validators and helpers -- `Runtime/Execution` - rendering, parsing, logging, provider calls -- `Runtime/Prompts` - prompt types and template plumbing -- `DataSeed` - seeded prompt and operation data -- `Generation/AIGenerationAppService.cs` - generation API +`AIGenerationOperations` is the single catalog for operation type, prompt family, +feature, permissions, and form-version requirement. -### Application.Contracts -- `IAIService.cs` - runtime contract -- `Generation/IAIGenerationAppService.cs` - generation app service contract -- `Generation/*ResultDto.cs` - queued result DTOs -- `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 | +| Operation | Type | Requires form version | | --- | --- | --- | -| 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`. +| Application Analysis | `application-analysis` | No | +| Attachment Summary | `attachment-summary` | No | +| Application Scoring | `application-scoring` | No | +| Form Mapping | `form-mapping` | Yes | +| Form Worksheet | `form-worksheet` | Yes | +| Form Scoresheet | `form-scoresheet` | Yes | + +User-triggered generation requires both the catalogued feature and generate permission. +Automatic intake enforces its tenant, form, feature, and generation prerequisites without +user permission authorization. Status reads require the corresponding view permission. + +See [configuration](./configuration.md), [pipeline](./operation-pipeline.md), and the +[implementation playbook](./implementation-playbook.md). diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operation-pipeline.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operation-pipeline.md index e31433e284..bfd5d555ac 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/operation-pipeline.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operation-pipeline.md @@ -1,6 +1,6 @@ # AI operation pipeline -AI generation uses a shared operation catalog and queued lifecycle. The catalog owns the operation key, seeded operation name, feature gate, permissions, and whether a form version is required. Submission enters `IAIGenerationAppService.SubmitAsync`, and the Grant Manager queue preserves the existing duplicate-request lock and operation-specific validation. +AI generation uses a shared operation catalog and queued lifecycle. The catalog owns the operation key, seeded operation name, feature gate, permissions, and whether a form version is required. UI submission enters `IAIGenerationAppService.SubmitAsync`; automatic intake checks its own preconditions and enters the Grant Manager queue directly. The queue preserves the duplicate-request lock and operation-specific validation. The generic background-job base owns tenant scope, structured logging, request state transitions, failure handling, and cooldown stamping. Operation-specific executors remain responsible for loading input, calling the AI contract, validating the response, and persisting the result. @@ -14,3 +14,6 @@ The generic background-job base owns tenant scope, structured logging, request s 6. Add focused catalog, lifecycle, executor, and persistence tests. Do not add another queue branch for shared lifecycle concerns. New operation behavior belongs in its executor; request locking, status transitions, tenant scope, logging, and cooldown behavior stay in the common pipeline. + +For Grant Manager queue and executor ownership, see the +[generation hand-off](../../../src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/README.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 index 1b2bfdb194..d1c87ae7b1 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md @@ -13,7 +13,7 @@ Generate an AI analysis of an application submission. - `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. +- Structured analysis output. The POST request enqueues generation and returns without the generated payload; clients use the shared status endpoint while the background executor persists the result. ## 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 index a19e1f8bb1..0fa174c45c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md @@ -13,7 +13,7 @@ Generate scored answers for a submitted application against an assigned scoreshe - `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. +- Structured scoring output. The POST request enqueues generation and returns without the generated payload; clients use the shared status endpoint while the background executor persists the result. ## Notes - The prompt asks for answers only for the configured section or scoresheet context. 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 index 5735c3996f..27e7386037 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md @@ -12,7 +12,7 @@ Generate summaries for selected application attachments. - `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. +- Structured attachment summary output. The POST request enqueues generation and returns without the generated payload; clients use the shared status endpoint while the background executor persists the result. ## 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 index 2881cefe9f..d7b66db156 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md @@ -18,7 +18,7 @@ Generate recommended CHEFS-to-Unity field mapping for a form version. - `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. +- Structured mapping recommendation JSON output. The POST request enqueues generation and returns without the generated payload; clients use the shared status endpoint while the background executor persists the result. ## Output Shape - Core field matches. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-scoresheet.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-scoresheet.md new file mode 100644 index 0000000000..dde122e67a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-scoresheet.md @@ -0,0 +1,21 @@ +# Form Scoresheet + +## Goal + +Generate and publish a scoresheet definition for a form version. + +## Inputs + +- Form version and form context +- Existing linked scoresheet, when present +- Existing scoresheet sections and fields + +## Surface + +- `POST /api/app/ai/generation/form-scoresheet` +- `GET /api/app/ai/generation/status` + +## Result + +The executor validates the generated scoresheet JSON, creates or replaces the form's +scoresheet, publishes it, and links it to the application form. 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 index 49d76dd9df..ffed292894 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md @@ -18,12 +18,13 @@ Generate a recommended worksheet definition for a form version. - `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. +- Structured worksheet field-suggestion JSON. The POST request enqueues generation and returns without the generated payload; clients use the shared status endpoint while the background executor validates the suggestions and creates an unpublished worksheet for review. ## 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. +- A `fields` collection containing the suggested additional worksheet fields. +- Each suggestion supplies the field key, label, and supported custom-field type. +- The executor builds the worksheet and its `Suggested Fields` section from the validated suggestions. +- Keep the result valid JSON and include only fields that the form needs beyond core Unity fields. ## 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 index c5b346acf7..8de9d3350c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md @@ -6,15 +6,18 @@ - `ApplicationScoring` - question scoring - `FormMapping` - CHEFS to Unity mapping - `FormWorksheet` - worksheet generation +- `FormScoresheet` - scoresheet generation ## Versions -- Built-in `v0`, `v1`, and `v2` prompt rows are defined and seeded by `AIPromptDataSeeder` -- Runtime selects the newest active prompt by family. +- Built-in prompt rows are defined and seeded by `AIPromptDataSeeder`. +- Families may have `v0`, `v1`, and `v2` rows; a new operation only needs the versions it supports. +- Without an explicit request version, runtime selects the newest active prompt by family. ## Tenant selection - `AIOperation.Name` is the prompt family; operations do not pin a prompt row or version. -- Host requests use the newest active global prompt in the family. -- Tenant requests use the newest active prompt owned by that tenant, falling back to the newest active global prompt. +- An explicit request version selects that active version, with the same tenant/global fallback. +- Otherwise, host requests use the newest active global prompt in the family. +- Otherwise, tenant requests use the newest active prompt owned by that tenant, falling back to the newest active global prompt. - To roll back a tenant or global prompt, deactivate the active version and leave the prior version active. - Tenant prompt rows are administrator-created; deployments seed only global prompts and operations. @@ -22,7 +25,7 @@ - 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. +- A new version should be additive and must not silently change an active prompt's 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/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 9fb8b7659d..90238c25b1 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 @@ -12,8 +12,7 @@ namespace Unity.AI.DataSeed; /// -/// Seeds the built-in AI prompts (application analysis, attachment summary, application scoring) into the host database. -/// Each prompt family is represented as versioned rows in AIPrompts. +/// Seeds host-owned, versioned built-in AI prompts. /// public class AIPromptDataSeeder( IRepository promptRepository, @@ -21,7 +20,10 @@ public class AIPromptDataSeeder( { public async Task SeedAsync(DataSeedContext context) { - if (context.TenantId != null) return; // host database only + if (context.TenantId != null) + { + return; + } using (currentTenant.Change(null)) { 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 582c7d1cfb..c436d59f06 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 @@ -25,6 +25,7 @@ public class AIGenerationAppService( [HttpPost("submit")] public virtual async Task SubmitAsync(string operationType, AIGenerationSubmissionDto request) { + // All generation routes converge here so authorization, feature, and form-version rules stay consistent. var operation = AIGenerationOperations.Get(operationType); await featureGuard.EnsureEnabledAsync(operation.FeatureName, operation.DisabledLocalizationKey); 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 6b6f1dc8a1..162efc619e 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 @@ -12,10 +12,8 @@ namespace Unity.AI.RateLimit; /// -/// Per-user cooldown for AI generate calls. KISS: a single cache entry per user -/// holds the cooldown end ticks; the cache TTL matches the cooldown so a missing -/// entry means the user can generate again. Anonymous/system callers are not -/// rate-limited (background event handlers also flow through the AI queue). +/// Per-user AI cooldown. Anonymous and system callers bypass it; activity providers +/// augment the state returned to authenticated users. /// public class AIRateLimiter( IDistributedCache cache, diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css index f0b5497147..0f18cea086 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css @@ -1,4 +1,50 @@ -body { +.notification-tooltip { + background: transparent; + border: 0; + cursor: help; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 20px; + height: 20px; + margin-left: 0.35rem; + padding: 0; + position: relative; + z-index: 2; + margin-top: -7px; +} + +.notification-tooltip-icon { + border: 2px solid rgb(46, 93, 215); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background-color: rgb(255, 255, 255); + color: rgb(46, 93, 215); + font-family: Georgia, serif; + font-size: 0.8rem; + font-weight: 700; + font-style: italic; + line-height: 22px; + transform: translateY(4px); +} + +.notification-tooltip:focus-visible { + outline: 2px solid #2e5dd7; + outline-offset: 2px; +} + +.notification-tooltip-popover .tooltip-inner { + font-size: 0.75rem; + line-height: 1.35; + max-width: 280px; + padding: 0.35rem 0.5rem; +} + +body { overflow-y:auto!important; } @@ -48,7 +94,7 @@ white-space: nowrap; transition: all 0.15s ease-in-out; border-radius: 4px; - font-size: 0.875rem; + font-size: 1rem; } .btn-add-user:hover:not(:disabled) { @@ -197,10 +243,16 @@ span.tooltip-wrapper { } .template-field { - flex: 0 0 135px !important; - min-width: 140px !important; + align-items: center; + box-sizing: border-box; + display: flex; + flex: 0 0 180px !important; + font-size: 0.975rem; + gap: 0.15rem; + min-width: 180px !important; white-space: nowrap; - margin: 0.5rem; + margin: 0.5rem 0.25rem 0.5rem 0.5rem; + width: 180px; } /* ── Drag ghost (prevent text selection while dragging) ───────────────────── */ diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js index e9c3c1652c..d62a88a5de 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js @@ -1,4 +1,13 @@ - +function initializeTooltips() { + if (typeof bootstrap === 'undefined') return; + + document.querySelectorAll('#nav-template [data-bs-toggle="tooltip"]').forEach((tooltipElement) => { + bootstrap.Tooltip.getOrCreateInstance(tooltipElement, { + customClass: 'notification-tooltip-popover' + }); + }); +} + $(function () { const UiElements = { saveButton: $("#saveTemplateBtn"), @@ -23,6 +32,7 @@ $(function () { function init() { $('#email-attachments-section').hide(); + initializeTooltips(); initializeTemplateDataTables(); initializeDivider(); initializeTabPersistence(); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml index e09b8ab96b..377a55840a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml @@ -29,7 +29,13 @@
- +
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Events/PaymentStatusChangedEvent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Events/PaymentStatusChangedEvent.cs new file mode 100644 index 0000000000..b1e5f0de6b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Events/PaymentStatusChangedEvent.cs @@ -0,0 +1,16 @@ +using System; +using Unity.Payments.Enums; + +namespace Unity.Payments.Events +{ + public class PaymentStatusChangedEvent + { + public Guid PaymentRequestId { get; set; } + + public Guid ApplicationId { get; set; } + + public PaymentRequestStatus Status { get; set; } + + public Guid? TenantId { get; set; } + } +} \ 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 6b3670d4cb..0e8b49e9a4 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 @@ -10,12 +10,14 @@ using Unity.Payments.Domain.Services; using Unity.Payments.Domain.Shared; using Unity.Payments.Enums; +using Unity.Payments.Events; using Unity.Payments.PaymentRequests.Notifications; using Unity.Payments.Permissions; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Data; +using Volo.Abp.EventBus.Local; using Volo.Abp.Features; using Volo.Abp.Users; @@ -31,7 +33,8 @@ public class PaymentRequestAppService( FsbPaymentNotifier fsbPaymentNotifier, IPaymentRequestQueryManager paymentRequestQueryManager, IPaymentRequestConfigurationManager paymentRequestConfigurationManager, - Lazy applicationLinksService) : PaymentsAppService, IPaymentRequestAppService + Lazy applicationLinksService, + ILocalEventBus localEventBus) : PaymentsAppService, IPaymentRequestAppService { public async Task GetDefaultAccountCodingId() @@ -60,6 +63,7 @@ public virtual async Task> CreateAsync(List> CreateHistoricalAsync(List GetNextBatchInfoAsync() { return await paymentRequestConfigurationManager.GetNextBatchInfoAsync(); @@ -212,6 +228,17 @@ public virtual async Task> UpdateStatusAsync(List CancelAsync(Guid paymentRequestId) .WithData("Status", payment.Status.ToString()); var result = await paymentsManager.CancelPaymentAsync(paymentRequestId); + + await localEventBus.PublishAsync(new PaymentStatusChangedEvent + { + PaymentRequestId = result.Id, + ApplicationId = result.CorrelationId, + Status = result.Status, + TenantId = CurrentTenant.Id + }); + return MapToPaymentRequestDto(result); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs index 9c8659e0d7..2c86cf9638 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs @@ -14,6 +14,8 @@ public class CreateUpdateNotificationDto [Required] public string TriggerType { get; set; } = "Event"; + public string? Module { get; set; } + public string? TriggerDetail { get; set; } public bool IsActive { get; set; } = true; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs index 815c3fadbe..f7071ef01f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs @@ -9,6 +9,7 @@ public class NotificationDto : EntityDto public Guid EmailTemplateId { get; set; } public string? TemplateName { get; set; } public string TriggerType { get; set; } = string.Empty; + public string? Module { get; set; } public string? TriggerDetail { get; set; } public bool IsActive { get; set; } public string? EventType { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs index 82200085a5..5e742ea5a5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs @@ -9,6 +9,7 @@ using Unity.Notifications.Events; using Unity.Notifications.Settings; using Unity.Notifications.Templates; +using Unity.Payments.Events; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus; @@ -34,7 +35,7 @@ internal class ScheduledNotificationEventHandler( ICurrentTenant currentTenant, ScheduledNotificationHelper scheduledNotificationHelper, ILogger logger) - : ILocalEventHandler, ITransientDependency + : ILocalEventHandler, ILocalEventHandler, ITransientDependency { public async Task HandleEventAsync(ApplicationChangedEvent eventData) { @@ -58,6 +59,7 @@ public async Task HandleEventAsync(ApplicationChangedEvent eventData) n => n.FormId == application.ApplicationFormId && n.TriggerType == "Event" && n.IsActive + && (n.Module == null || n.Module == "Application") && n.ApplicationStatusId == application.ApplicationStatusId)) .ToList(); @@ -83,6 +85,53 @@ public async Task HandleEventAsync(ApplicationChangedEvent eventData) } } + public async Task HandleEventAsync(PaymentStatusChangedEvent eventData) + { + if (!await featureChecker.IsEnabledAsync("Unity.Notifications")) + { + return; + } + + try + { + var application = await applicationRepository.GetAsync(eventData.ApplicationId, includeDetails: true); + if (application == null) + { + logger.LogWarning("ScheduledNotificationEventHandler: Application {ApplicationId} not found for payment {PaymentRequestId}.", + eventData.ApplicationId, eventData.PaymentRequestId); + return; + } + + var notifications = (await scheduledNotificationRepository.GetListAsync( + n => n.FormId == application.ApplicationFormId + && n.TriggerType == "Event" + && n.IsActive + && n.Module == "Payment" + && n.EventType == eventData.Status.ToString())) + .ToList(); + + if (notifications.Count == 0) + { + return; + } + + var defaultFromAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); + string emailFrom = defaultFromAddress ?? "NoReply@gov.bc.ca"; + var applicantAgent = await applicantAgentRepository.FirstOrDefaultAsync(a => a.ApplicationId == application.Id); + + foreach (var notification in notifications) + { + await ProcessNotificationAsync(notification, application, applicantAgent, emailFrom); + } + } + catch (Exception ex) + { + logger.LogError(ex, + "ScheduledNotificationEventHandler: Error processing payment event for payment {PaymentRequestId}.", + eventData.PaymentRequestId); + } + } + private async Task ProcessNotificationAsync( ScheduledNotification notification, Application application, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs index fa2331a192..c29d5df821 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs @@ -138,6 +138,7 @@ private async Task EnsureRequestAndEnqueueAsync( var persistedOperation = await ResolveOperationAsync(operation); var requestLock = distributedLockProvider.CreateLock($"ai-generation:{tenantId}:{request.ApplicationId}:{persistedOperation.Id}"); + // The lock must cover the active-request check so each tenant/application/operation queues only once. using (await requestLock.AcquireAsync()) { var query = await generationRequestRepository.GetQueryableAsync(); @@ -159,8 +160,7 @@ private async Task EnsureRequestAndEnqueueAsync( await validateInput(); - // Single chokepoint for all AI generate flows (manual + auto). - // The limiter is a no-op for system/background callers without an authenticated user. + // Manual and automatic flows share this user-scoped limiter; system callers bypass it. await aiRateLimiter.EnsureAsync(currentUser.Id); var generationRequest = new AIGenerationRequest( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs index 3252f0df84..04d0768fb8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs @@ -20,6 +20,7 @@ public sealed class AIGenerationBackgroundJob( { public override async Task ExecuteAsync(AIGenerationBackgroundJobArgs args) { + // The job owns tenant scope and request lifecycle; executors own AI input and result persistence. using var logScope = AIGenerationLogScope.Begin( logger, args.OperationType, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs index 2459de85c8..926ae0448d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs @@ -27,6 +27,7 @@ public async Task HandleEventAsync(ApplicationProcessEvent eventData) return; } + // Automatic generation requires tenant and form opt-in plus at least one enabled intake feature. var automaticGenerationEnabled = await settingProvider.GetAsync(AISettings.AutomaticGenerationEnabled, defaultValue: false); if (!automaticGenerationEnabled) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/README.md b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/README.md new file mode 100644 index 0000000000..0dce7162d8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/README.md @@ -0,0 +1,13 @@ +# Grant Manager AI Generation + +This folder owns the Grant Manager side of AI generation. Unity.AI owns shared +contracts, runtime execution, and database configuration; Grant Manager owns request +queueing, background execution, application-specific input, and result persistence. + +`ApplicationGenerationQueue` serializes queueing per tenant, application, and +operation, then records one active request before enqueuing its job. +`AIGenerationBackgroundJob` owns tenant scope and request lifecycle state. +Operation executors own their input and persistence; they must not duplicate queue, +status, or cooldown behavior. + +See the shared [Unity.AI operation pipeline](../../../../../modules/Unity.AI/docs/operation-pipeline.md). diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs index fba613275c..b7b5c0ac56 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -19,6 +19,7 @@ public async Task CreateAsync(CreateUpdateNotificationDto input FormId = input.FormId, EmailTemplateId = input.EmailTemplateId, TriggerType = input.TriggerType, + Module = input.Module, TriggerDetail = input.TriggerDetail, IsActive = input.IsActive, EventType = input.EventType, @@ -38,6 +39,7 @@ public async Task CreateAsync(CreateUpdateNotificationDto input EmailTemplateId = entity.EmailTemplateId, TemplateName = null, TriggerType = entity.TriggerType, + Module = entity.Module, TriggerDetail = entity.TriggerDetail, IsActive = entity.IsActive, EventType = entity.EventType, @@ -69,6 +71,7 @@ public async Task GetAsync(Guid id) EmailTemplateId = e.EmailTemplateId, TemplateName = null, TriggerType = e.TriggerType, + Module = e.Module, TriggerDetail = e.TriggerDetail, IsActive = e.IsActive, EventType = e.EventType, @@ -104,6 +107,7 @@ public async Task> GetListAsync(GetNotifications EmailTemplateId = e.EmailTemplateId, TemplateName = null, TriggerType = e.TriggerType, + Module = e.Module, TriggerDetail = e.TriggerDetail, IsActive = e.IsActive, EventType = e.EventType, @@ -122,6 +126,7 @@ public async Task UpdateAsync(Guid id, CreateUpdateNotification var e = await _repository.GetAsync(id); e.EmailTemplateId = input.EmailTemplateId; e.TriggerType = input.TriggerType; + e.Module = input.Module; e.TriggerDetail = input.TriggerDetail; e.IsActive = input.IsActive; e.EventType = input.EventType; @@ -140,6 +145,7 @@ public async Task UpdateAsync(Guid id, CreateUpdateNotification EmailTemplateId = e.EmailTemplateId, TemplateName = null, TriggerType = e.TriggerType, + Module = e.Module, TriggerDetail = e.TriggerDetail, IsActive = e.IsActive, EventType = e.EventType, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs index 6cd43e72fc..56d66838d9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs @@ -17,6 +17,8 @@ public class ScheduledNotification : FullAuditedAggregateRoot, IMultiTenan public string TriggerType { get; set; } = string.Empty; // Date or Event + public string? Module { get; set; } + public string? TriggerDetail { get; set; } public bool IsActive { get; set; } = true; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs index fefe53b707..f26b4a7abb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -433,6 +433,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(x => x.FormId).IsRequired(); b.Property(x => x.EmailTemplateId).IsRequired(); b.Property(x => x.TriggerType).IsRequired().HasMaxLength(64); + b.Property(x => x.Module).HasMaxLength(64); b.Property(x => x.TriggerDetail).HasMaxLength(1000); b.Property(x => x.EventType).HasMaxLength(128); b.Property(x => x.ApplicationStatus).HasMaxLength(128); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.Designer.cs new file mode 100644 index 0000000000..3aeede4d72 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.Designer.cs @@ -0,0 +1,15 @@ +// +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Unity.GrantManager.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260804193000_AddModuleToScheduledNotifications")] + partial class AddModuleToScheduledNotifications + { + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.cs new file mode 100644 index 0000000000..e7a5e4a498 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class AddModuleToScheduledNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Module", + schema: "Notifications", + table: "ScheduledNotifications", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.Sql(@" + UPDATE ""Notifications"".""ScheduledNotifications"" + SET ""Module"" = 'Application' + WHERE ""TriggerType"" = 'Event';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Module", + schema: "Notifications", + table: "ScheduledNotifications"); + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs index 1d8fd17a8c..49f2570401 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs @@ -3114,6 +3114,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("Module") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("IsDeleted") .ValueGeneratedOnAdd() .HasColumnType("boolean") diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs index 1850dcb94e..7e6533d639 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -8,6 +8,7 @@ using Unity.Notifications.Emails; using Volo.Abp.Users; using Unity.GrantManager.Events; +using Unity.Payments.Enums; using Volo.Abp.Identity.Integration; namespace Unity.GrantManager.Web.Controllers @@ -40,6 +41,15 @@ public FormNotificationsApiController(IApplicationStatusService statusService, I _grantApplicationAppService = grantApplicationAppService; _scheduledNotificationHelper = scheduledNotificationHelper; } + + [HttpGet("payment-statuses")] + public ActionResult> GetPaymentStatuses() + { + var statuses = Enum.GetNames() + .Select(status => (object)new { id = status, internalStatus = status }) + .ToList(); + return Ok(statuses); + } // In-memory storage removed; persisting to ScheduledNotifications table via IAutomatedNotificationAppService @@ -245,8 +255,9 @@ public async Task>> GetForForm(strin TemplateId = e.EmailTemplateId, TemplateName = templateMap.TryGetValue(e.EmailTemplateId, out var t) && t != null ? t.Name : string.Empty, TriggerType = e.TriggerType, + Module = e.Module ?? (e.TriggerType == "Event" ? "Application" : null), DateType = e.DateField, - EventStatus = e.ApplicationStatus, + EventStatus = e.EventType ?? e.ApplicationStatus, ApplicationStatusId = e.ApplicationStatusId, RecipientCategory = e.RecipientCategory, RecipientIdentifier = e.RecipientIdentifier, @@ -262,11 +273,27 @@ public async Task> CreateForForm(string f { if (input.TemplateId == Guid.Empty) return BadRequest("TemplateId required"); + if (!ValidateModule(input, out var moduleError)) return BadRequest(moduleError); + if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(input.RecipientIdentifier)) { return BadRequest("RecipientIdentifier required for Event trigger"); } + if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase) && + string.Equals(input.Module, "Payment", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(input.EventStatus)) + { + return BadRequest("EventStatus required for Event trigger"); + } + + if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase) && + string.Equals(input.Module, "Application", StringComparison.OrdinalIgnoreCase) && + !input.ApplicationStatusId.HasValue) + { + return BadRequest("ApplicationStatusId required for Application event trigger"); + } + var template = await _templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); @@ -285,10 +312,11 @@ public async Task> CreateForForm(string f FormId = parsedFormId, EmailTemplateId = template.Id, TriggerType = input.TriggerType, - TriggerDetail = input.TriggerType == "Date" ? input.DateType : statusLabel, + Module = input.Module, + TriggerDetail = input.TriggerType == "Date" ? input.DateType : input.Module == "Payment" ? input.EventStatus : statusLabel, IsActive = true, - EventType = null, - ApplicationStatusId = input.ApplicationStatusId, + EventType = input.Module == "Payment" ? input.EventStatus : null, + ApplicationStatusId = input.Module == "Application" ? input.ApplicationStatusId : null, ApplicationStatus = statusLabel, DateField = input.DateType, RecipientCategory = input.RecipientCategory, @@ -303,8 +331,9 @@ public async Task> CreateForForm(string f TemplateId = input.TemplateId, TemplateName = template.Name, TriggerType = created.TriggerType, + Module = created.Module, DateType = created.DateField, - EventStatus = created.ApplicationStatus, + EventStatus = created.EventType ?? created.ApplicationStatus, ApplicationStatusId = created.ApplicationStatusId, RecipientCategory = created.RecipientCategory, RecipientIdentifier = created.RecipientIdentifier, @@ -355,6 +384,8 @@ public async Task> UpdateForForm(string f if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); if (input.TemplateId == Guid.Empty) return BadRequest("TemplateId required"); + if (!ValidateModule(input, out var moduleError)) return BadRequest(moduleError); + var template = await _templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); @@ -371,10 +402,11 @@ public async Task> UpdateForForm(string f FormId = parsedFormId, EmailTemplateId = template.Id, TriggerType = input.TriggerType, - TriggerDetail = input.TriggerType == "Date" ? input.DateType : statusLabel, + Module = input.Module, + TriggerDetail = input.TriggerType == "Date" ? input.DateType : input.Module == "Payment" ? input.EventStatus : statusLabel, IsActive = true, - EventType = null, - ApplicationStatusId = input.ApplicationStatusId, + EventType = input.Module == "Payment" ? input.EventStatus : null, + ApplicationStatusId = input.Module == "Application" ? input.ApplicationStatusId : null, ApplicationStatus = statusLabel, DateField = input.DateType, RecipientCategory = input.RecipientCategory, @@ -389,8 +421,9 @@ public async Task> UpdateForForm(string f TemplateId = input.TemplateId, TemplateName = template.Name, TriggerType = updated.TriggerType, + Module = updated.Module, DateType = updated.DateField, - EventStatus = updated.ApplicationStatus, + EventStatus = updated.EventType ?? updated.ApplicationStatus, ApplicationStatusId = updated.ApplicationStatusId, RecipientCategory = updated.RecipientCategory, RecipientIdentifier = updated.RecipientIdentifier, @@ -399,6 +432,30 @@ public async Task> UpdateForForm(string f return Ok(dto); } + + private static bool ValidateModule(CreateScheduledNotificationInput input, out string error) + { + error = string.Empty; + if (!string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(input.Module)) + { + error = "Module required for Event trigger"; + return false; + } + + if (!string.Equals(input.Module, "Application", StringComparison.OrdinalIgnoreCase) && + !string.Equals(input.Module, "Payment", StringComparison.OrdinalIgnoreCase)) + { + error = "Module must be Application or Payment"; + return false; + } + + return true; + } } public record EmailTemplateDto @@ -418,6 +475,7 @@ public record ScheduledNotificationDto public Guid TemplateId { get; init; } public string TemplateName { get; init; } = string.Empty; public string TriggerType { get; init; } = string.Empty; + public string? Module { get; init; } public string? DateType { get; init; } public string? EventStatus { get; init; } public Guid? ApplicationStatusId { get; init; } @@ -431,6 +489,7 @@ public record CreateScheduledNotificationInput { public Guid TemplateId { get; init; } public string TriggerType { get; init; } = "Date"; + public string? Module { get; init; } public string? DateType { get; init; } public Guid? ApplicationStatusId { get; init; } public string? EventStatus { get; init; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index 167abe5906..a361aef984 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -912,25 +912,31 @@ $select.find('option').not($placeholder).remove(); const seenTemplateIds = new Set(); - templates.forEach((template) => { - const templateName = template.name || template.Name || 'Unnamed Template'; - const templateId = (template.id || template.Id || '').toString(); - if (!templateId || seenTemplateIds.has(templateId)) { - return; - } + [...templates] + .sort((left, right) => { + const leftName = (left.name || left.Name || 'Unnamed Template').trim(); + const rightName = (right.name || right.Name || 'Unnamed Template').trim(); + return leftName.localeCompare(rightName, undefined, { sensitivity: 'base' }); + }) + .forEach((template) => { + const templateName = template.name || template.Name || 'Unnamed Template'; + const templateId = (template.id || template.Id || '').toString(); + if (!templateId || seenTemplateIds.has(templateId)) { + return; + } - seenTemplateIds.add(templateId); + seenTemplateIds.add(templateId); - const $option = $('
+
+ + +
Please select a module.
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css index d6873d19c4..2b5c676da4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css @@ -17,8 +17,18 @@ .notifications-widget .card { border: 0; } .notifications-widget .card .card-body { background: #fff; } -/* Select2 Bootstrap 5 Theme - Use default styling */ -/* Let Select2's Bootstrap 5 theme handle the layout naturally */ +/* Keep every notification form control aligned to the left column. */ +#notificationForm .left-col .form-select, +#notificationForm .left-col .form-control, +#notificationForm .left-col .select2, +#notificationForm .left-col .select2-container { + display: block; + width: 100% !important; + max-width: 100%; + box-sizing: border-box; +} + +/* Select2 Bootstrap 5 theme */ .select2-container--bootstrap-5 .select2-selection--multiple { min-height: 38px; height: auto; @@ -63,11 +73,13 @@ display: flex; flex: 1 1 auto; min-height: 0; + min-width: 0; } .left-col { - flex: 0 0 33%; + flex: 0 1 33%; min-width: 320px; + max-width: 100%; overflow-y: auto; } @@ -89,7 +101,8 @@ .notification-modal-content { display: flex; flex-direction: column; - min-width: 900px; + width: min(100%, 1200px); + min-width: min(900px, 100%); min-height: 480px; max-height: 85vh; } @@ -106,6 +119,27 @@ background-color: #f8f9fb; } +@media (max-width: 991.98px) { + #modalColumns { + flex-direction: column; + gap: 1.5rem; + } + + .left-col { + flex: 0 1 auto; + min-width: 0; + } + + .right-col { + min-height: 220px; + } + + .notification-modal-content { + min-width: 0; + width: 100%; + } +} + /* Notification info note styling */ .notification-info-note { background-color: #d1ecf1; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js index 0d17c84bd3..837d7effe6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js @@ -137,6 +137,9 @@ function fetchStatuses() { return fetch('/api/form-notifications/statuses').then(r => r.json()); } + function fetchPaymentStatuses() { + return fetch('/api/form-notifications/payment-statuses').then(r => r.json()); + } function fetchRecipients(category) { return fetch('/api/form-notifications/recipients?category=' + encodeURIComponent(category)).then(r => r.json()); @@ -158,11 +161,19 @@ return detail; } + function renderTriggerType(data, type, row) { + if (row.triggerType === 'Event' && row.module) { + return 'Event - ' + row.module; + } + + return row.triggerType || ''; + } + function getNotificationColumns() { let index = 0; return [ { title: 'Template', name: 'templateName', data: 'templateName', visible: true, index: index++ }, - { title: 'Trigger Type', name: 'triggerType', data: 'triggerType', visible: true, index: index++ }, + { title: 'Trigger Type', name: 'triggerType', data: 'triggerType', visible: true, index: index++, render: renderTriggerType }, { title: 'Trigger Detail',name: 'triggerDetail',data: null, visible: true, orderable: true, defaultContent: '', index: index++, render: renderTriggerDetail }, { title: 'Status', name: 'status', data: 'isActive', visible: true, orderable: true, index: index++, @@ -343,7 +354,7 @@ if (modalEl) { modalEl.dataset.editId = row.id; } - document.getElementById('notificationModal')?.addEventListener('shown.bs.modal', function () { + document.getElementById('notificationModal')?.addEventListener('shown.bs.modal', async function () { const setVal = (id, val) => { document.getElementById(id).value = val ?? ''; }; @@ -374,7 +385,9 @@ const values = row.recipientIdentifier ? row.recipientIdentifier.split(',').map(v => v.trim()) : []; setSelectedRecipients(values); } else if (row.triggerType === 'Event') { - setVal('statusSelect', row.applicationStatusId); + setVal('moduleSelect', row.module); + await loadStatusesForModule(row.module); + setVal('statusSelect', row.applicationStatusId || row.eventStatus); setVal('recipientCategory', row.recipientCategory); // Set multiple values for recipient select const values = row.recipientIdentifier ? row.recipientIdentifier.split(',').map(v => v.trim()) : []; @@ -392,13 +405,15 @@ blank.value = ''; blank.text = ''; sel.appendChild(blank); - templates.forEach(t => { - const opt = document.createElement('option'); - // Use template id as the option value so we can reference templates reliably - opt.value = t.id; - opt.text = t.name + ' — ' + t.subject; - sel.appendChild(opt); - }); + [...templates] + .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })) + .forEach(t => { + const opt = document.createElement('option'); + // Use template id as the option value so we can reference templates reliably + opt.value = t.id; + opt.text = t.name + ' — ' + t.subject; + sel.appendChild(opt); + }); updatePreview(); } @@ -417,6 +432,25 @@ sel.appendChild(opt); }); } + async function loadStatusesForModule(module) { + const statusSelect = document.getElementById('statusSelect'); + if (!statusSelect) return; + + statusSelect.innerHTML = ''; + const blank = document.createElement('option'); + blank.value = ''; + blank.text = ''; + statusSelect.appendChild(blank); + statusSelect.disabled = !module; + + if (!module) return; + + const statuses = module === 'Payment' + ? await fetchPaymentStatuses() + : await fetchStatuses(); + populateStatuses(statuses); + statusSelect.disabled = false; + } function populateRecipients(list) { const sel = document.getElementById('recipientSelect'); @@ -528,9 +562,10 @@ function showModal() { resetValidationState(); - ['templateSelect', 'triggerType', 'dateType', 'statusSelect', 'recipientCategory'].forEach(id => { + ['templateSelect', 'triggerType', 'dateType', 'moduleSelect', 'statusSelect', 'recipientCategory'].forEach(id => { document.getElementById(id).value = ''; }); + document.getElementById('statusSelect').disabled = true; // Clear the recipient select clearSelectedRecipients(); @@ -554,7 +589,7 @@ const requiredAlways = ['templateSelect', 'triggerType']; const requiredForDate = ['dateType', 'recipientCategory', 'recipientSelect']; - const requiredForEvent = ['statusSelect', 'recipientCategory', 'recipientSelect']; + const requiredForEvent = ['moduleSelect', 'statusSelect', 'recipientCategory', 'recipientSelect']; const fieldsToValidate = [ ...requiredAlways, @@ -683,7 +718,7 @@ e.target.classList.remove('is-invalid'); updatePreview(); }); - ['dateType', 'statusSelect'].forEach(id => { + ['dateType', 'moduleSelect', 'statusSelect'].forEach(id => { document.getElementById(id)?.addEventListener('change', (e) => { e.target.classList.remove('is-invalid'); }); @@ -706,6 +741,13 @@ dateOptionsEl?.classList.add('hidden-section'); eventOptionsEl?.classList.remove('hidden-section'); recipientOptionsEl?.classList.remove('hidden-section'); + const moduleSelect = document.getElementById('moduleSelect'); + const statusSelect = document.getElementById('statusSelect'); + if (moduleSelect?.value) { + loadStatusesForModule(moduleSelect.value); + } else if (statusSelect) { + statusSelect.disabled = true; + } } else { dateOptionsEl?.classList.add('hidden-section'); eventOptionsEl?.classList.add('hidden-section'); @@ -715,6 +757,14 @@ e.target.classList.remove('is-invalid'); }); + document.getElementById('moduleSelect')?.addEventListener('change', (e) => { + e.target.classList.remove('is-invalid'); + loadStatusesForModule(e.target.value).catch(err => { + console.error('Failed to load module statuses', err); + abp.notify.error('Failed to load status triggers'); + }); + }); + document.getElementById('recipientCategory')?.addEventListener('change', (e) => { const cat = e.target.value; e.target.classList.remove('is-invalid'); @@ -748,19 +798,22 @@ const templateId = (document.getElementById('templateSelect').value || '').trim(); const dateType = document.getElementById('dateType').value; - const applicationStatusId = document.getElementById('statusSelect')?.value; + const module = document.getElementById('moduleSelect')?.value; + const statusValue = document.getElementById('statusSelect')?.value; const recipientCategory = document.getElementById('recipientCategory')?.value; // Collect multiple selected recipients as comma-separated string const recipientIdentifier = getSelectedRecipients().join(','); - const resolvedStatusId = triggerType === 'Event' ? (applicationStatusId || null) : null; + const resolvedStatusId = triggerType === 'Event' && module === 'Application' ? (statusValue || null) : null; const bodyObj = { templateId: templateId, triggerType: triggerType, + module: triggerType === 'Event' ? module : null, dateType: triggerType === 'Date' ? dateType : null, applicationStatusId: resolvedStatusId, + eventStatus: triggerType === 'Event' && module === 'Payment' ? (statusValue || null) : null, recipientCategory: recipientCategory, recipientIdentifier: recipientIdentifier }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.js index 1e647c841a..84842f10ed 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.js @@ -127,12 +127,14 @@ function handleTemplatesList(list) { const sel = document.getElementById('cf_template'); sel.innerHTML = ''; - list.forEach(t => { + [...list] + .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })) + .forEach(t => { const opt = document.createElement('option'); opt.value = String(t.id); opt.text = `${t.name} — ${t.subject}`; sel.appendChild(opt); - }); + }); updatePreview(); return list; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js index 811d084201..4361b778f1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js @@ -149,12 +149,14 @@ function handleTemplatesList(list) { const sel = document.getElementById('cf_template'); sel.innerHTML = ''; - list.forEach(t => { + [...list] + .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })) + .forEach(t => { const opt = document.createElement('option'); opt.value = String(t.id); opt.text = `${t.name} — ${t.subject}`; sel.appendChild(opt); - }); + }); updatePreview(); return list; }