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 = $('