From 7148d663038e7d7c778df48b6b1d2af4dd05b6e1 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 7 Apr 2026 12:03:11 -0700 Subject: [PATCH 01/47] AB#32455 hotfix for applicant electoral district intake --- .../scripts/ApplicantElectoralUpdate.md | 64 ++++ .../Generate-ElectoralDistrictFixes.ps1 | 198 +++++++++++ .../scripts/GetElectoralDistrictData.sql | 12 + .../scripts/Validate-ElectoralDistricts.ps1 | 310 ++++++++++++++++++ .../DetermineElectoralDistrictHandler.cs | 38 ++- .../Applications/ApplicantAddress.cs | 10 +- 6 files changed, 620 insertions(+), 12 deletions(-) create mode 100644 applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md create mode 100644 applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 create mode 100644 applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql create mode 100644 applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 diff --git a/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md new file mode 100644 index 0000000000..f9218514d9 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md @@ -0,0 +1,64 @@ +# Applicant Electoral District Update + +## Overview + +This process cross-references application electoral districts against the BC Geocoder API and generates SQL fix scripts for any mismatches. + +## Steps + +### 1. Extract Data from the Database + +Run `GetElectoralDistrictData.sql` against the database and save the results as a CSV file in the `data/` folder: + +``` +data/electoral_districts.csv +``` + +The query returns all applications joined with their applicant addresses, including `ApplicationId`, `ReferenceNo`, `ApplicantElectoralDistrict`, `Street`, `Street2`, `City`, and `AddressType`. + +### 2. Validate Electoral Districts + +Run `Validate-ElectoralDistricts.ps1` with the extracted CSV. This geocodes each unique address via the BC Geocoder API, looks up the electoral district from the WFS endpoint, and compares it to the value stored in the database. + +```powershell +.\Validate-ElectoralDistricts.ps1 ` + -InputCsv ".\data\electoral_districts.csv" ` + -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" ` + -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=" +``` + +This produces a validated CSV with match results: + +``` +data/electoral_districts_validated.csv +``` + +Each row includes the expected electoral district, geocoder score, and a `DistrictMatch` column (`MATCH`, `MISMATCH`, `UNKNOWN`, or `SKIPPED`). + +### 3. Generate SQL Fix Scripts + +Run `Generate-ElectoralDistrictFixes.ps1` against the validated CSV. This produces SQL UPDATE statements for mismatched rows. + +```powershell +.\Generate-ElectoralDistrictFixes.ps1 ` + -InputCsv ".\data\electoral_districts_validated.csv" ` + -MinScore 70 ` + -AddressType 1 ` + -IncludeLowConfidence +``` + +This generates two SQL scripts: + +| File | Description | +|------|-------------| +| `electoral_districts_validated_update.sql` | High-confidence updates (score >= 70) — sets the district to the expected value | +| `electoral_districts_validated_nullify.sql` | Low-confidence updates (score < 70) — sets the district to NULL | + +Both scripts use conditional updates that only apply when the current database value still matches the value at extract time, preventing overwrites of legitimate changes. + +### 4. Apply the Fix Scripts + +Review the generated SQL files, then execute them against the database: + +1. **Always apply the high-confidence script first** (`_update.sql`). +2. **Optionally apply the low-confidence script** (`_nullify.sql`) if you want to clear unreliable district values. diff --git a/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 new file mode 100644 index 0000000000..4dbe27756c --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 @@ -0,0 +1,198 @@ +<# +.SYNOPSIS + Generates PostgreSQL UPDATE statements to fix mismatched electoral districts. + +.DESCRIPTION + Reads the validated CSV output from Validate-ElectoralDistricts.ps1 and generates + SQL UPDATE statements for rows where: + - DistrictMatch is MISMATCH and GeocoderScore >= MinScore: SET to the expected district (main file) + - DistrictMatch is MISMATCH and GeocoderScore < MinScore: SET to NULL (separate file, opt-in) + - AddressType matches the specified type (1=Physical, 2=Mailing) + +.PARAMETER InputCsv + Path to the validated CSV file (output of Validate-ElectoralDistricts.ps1). + +.PARAMETER OutputSql + Path to the high-confidence output .sql file. Defaults to _update.sql. + +.PARAMETER MinScore + Minimum geocoder score to trust. Rows at or above get the expected district; + rows below get NULL (if -IncludeLowConfidence is set). Default: 70. + +.PARAMETER AddressType + AddressType to filter on. 1 = Physical, 2 = Mailing. Default: 1 (Physical). + +.PARAMETER IncludeLowConfidence + When set, generates a separate .sql file for low-confidence rows (score < MinScore) + that sets ApplicantElectoralDistrict to NULL. File is _nullify.sql. + +.EXAMPLE + .\Generate-ElectoralDistrictFixes.ps1 ` + -InputCsv ".\data\electoral_districts_validated.csv" ` + -MinScore 70 ` + -AddressType 1 ` + -IncludeLowConfidence +#> +param( + [Parameter(Mandatory)] + [string]$InputCsv, + + [string]$OutputSql = "", + + [int]$MinScore = 70, + + [ValidateSet("1", "2")] + [string]$AddressType = "1", + + [switch]$IncludeLowConfidence +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ── Output path defaults ────────────────────────────────────────────── +$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv)) +$name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv) + +if (-not $OutputSql) { + $OutputSql = Join-Path $dir "${name}_update.sql" +} +$NullifySql = Join-Path $dir "${name}_nullify.sql" + +# ── Read CSV ────────────────────────────────────────────────────────── +$data = Import-Csv -Path $InputCsv +Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan + +# ── Resolve address type label ──────────────────────────────────────── +$addressTypeLabel = switch ($AddressType) { + "1" { "Physical" } + "2" { "Mailing" } +} +Write-Host "Filtering for AddressType: $AddressType ($addressTypeLabel)" -ForegroundColor Cyan + +# ── Filter all mismatches for this address type ────────────────────── +$allMismatches = @($data | Where-Object { + $_.DistrictMatch -eq "MISMATCH" -and + $_.GeocoderScore -ne "" -and + $_.AddressType -eq $AddressType +}) + +# Split into high-confidence (update to expected) and low-confidence (set to NULL) +$highConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -ge $MinScore }) +$lowConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -lt $MinScore }) + +Write-Host "Found $($allMismatches.Count) total MISMATCH rows for AddressType = $AddressType ($addressTypeLabel)" -ForegroundColor Cyan +Write-Host " High confidence (score >= $MinScore): $($highConfidence.Count) -> will SET to expected district" -ForegroundColor Green +$lowConfMsg = if ($IncludeLowConfidence) { " -> will SET to NULL (separate file)" } else { " (skipped, use -IncludeLowConfidence to generate)" } +Write-Host " Low confidence (score < $MinScore): $($lowConfidence.Count)$lowConfMsg" -ForegroundColor Yellow + +if ($highConfidence.Count -eq 0 -and (-not $IncludeLowConfidence -or $lowConfidence.Count -eq 0)) { + Write-Host "No rows to update. Exiting." -ForegroundColor Yellow + return +} + +# ── Helper ─────────────────────────────────────────────────────────── +function Escape-SqlString { + param([string]$value) + return $value.Replace("'", "''") +} + +# ── Generate high-confidence SQL ───────────────────────────────────── +if ($highConfidence.Count -gt 0) { + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine("-- Electoral District Fix Script (High Confidence)") + [void]$sb.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + [void]$sb.AppendLine("-- Source: $InputCsv") + [void]$sb.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score >= $MinScore") + [void]$sb.AppendLine("-- Total updates: $($highConfidence.Count)") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("BEGIN;") + [void]$sb.AppendLine("") + + foreach ($row in $highConfidence) { + $appId = $row.ApplicationId.Trim() + $parsedGuid = [System.Guid]::Empty + if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) { + Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red + continue + } + $appId = $parsedGuid.ToString() + $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() + $expectedED = Escape-SqlString $row.ExpectedElectoralDistrict.Trim() + $score = $row.GeocoderScore + $refNo = $row.ReferenceNo + + [void]$sb.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> '$expectedED'") + [void]$sb.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = '$expectedED' WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';") + [void]$sb.AppendLine("") + } + + [void]$sb.AppendLine("COMMIT;") + $sb.ToString() | Out-File -FilePath $OutputSql -Encoding UTF8 + Write-Host "`nHigh-confidence SQL written to: $OutputSql" -ForegroundColor Green +} +else { + Write-Host "`nNo high-confidence rows to write." -ForegroundColor DarkGray +} + +# ── Generate low-confidence SQL (separate file, opt-in) ────────────── +if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) { + $sbNull = [System.Text.StringBuilder]::new() + [void]$sbNull.AppendLine("-- Electoral District Nullify Script (Low Confidence)") + [void]$sbNull.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + [void]$sbNull.AppendLine("-- Source: $InputCsv") + [void]$sbNull.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score < $MinScore") + [void]$sbNull.AppendLine("-- Total updates: $($lowConfidence.Count)") + [void]$sbNull.AppendLine("-- Action: SET ApplicantElectoralDistrict = NULL (unreliable geocoding)") + [void]$sbNull.AppendLine("") + [void]$sbNull.AppendLine("BEGIN;") + [void]$sbNull.AppendLine("") + + foreach ($row in $lowConfidence) { + $appId = $row.ApplicationId.Trim() + $parsedGuid = [System.Guid]::Empty + if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) { + Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red + continue + } + $appId = $parsedGuid.ToString() + $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() + $score = $row.GeocoderScore + $refNo = $row.ReferenceNo + + [void]$sbNull.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> NULL") + [void]$sbNull.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = NULL WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';") + [void]$sbNull.AppendLine("") + } + + [void]$sbNull.AppendLine("COMMIT;") + $sbNull.ToString() | Out-File -FilePath $NullifySql -Encoding UTF8 + Write-Host "Low-confidence SQL written to: $NullifySql" -ForegroundColor Yellow +} +elseif ($IncludeLowConfidence) { + Write-Host "`nNo low-confidence rows to write." -ForegroundColor DarkGray +} + +# ── Summary ─────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "=============================" -ForegroundColor White +Write-Host " SQL Generation Summary" -ForegroundColor White +Write-Host "=============================" -ForegroundColor White +Write-Host "Address type: $AddressType ($addressTypeLabel)" +Write-Host "Score threshold: $MinScore" +Write-Host "-----------------------------" +Write-Host "High confidence: $($highConfidence.Count) (SET to expected)" -ForegroundColor Green +Write-Host "Low confidence: $($lowConfidence.Count) (SET to NULL)" -ForegroundColor Yellow +Write-Host "Total mismatches: $($allMismatches.Count)" +Write-Host "-----------------------------" +if ($highConfidence.Count -gt 0) { + Write-Host "Update file: $OutputSql" -ForegroundColor White +} +if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) { + Write-Host "Nullify file: $NullifySql" -ForegroundColor White +} +elseif ($lowConfidence.Count -gt 0) { + Write-Host "Nullify file: (not generated - use -IncludeLowConfidence)" -ForegroundColor DarkGray +} +Write-Host "" +Write-Host "Review the SQL file(s), then execute against your PostgreSQL database." -ForegroundColor Yellow diff --git a/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql new file mode 100644 index 0000000000..1fa5aa3a0b --- /dev/null +++ b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql @@ -0,0 +1,12 @@ +SELECT ap."CreationTime", +ap."Id" as "ApplicationId", +ap."ReferenceNo", +ap."ApplicantElectoralDistrict", +ad."Id" as "AddressId", +ad."Street", +ad."Street2", +ad."City", +ad."AddressType" +FROM public."Applications" ap +LEFT JOIN "ApplicantAddresses" ad on ad."ApplicationId" = ap."Id" +ORDER BY ap."CreationTime" DESC \ No newline at end of file diff --git a/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 new file mode 100644 index 0000000000..5889bf4c2a --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 @@ -0,0 +1,310 @@ +<# +.SYNOPSIS + Cross-references electoral districts in a CSV against the BC Geocoder API. + +.DESCRIPTION + Reads a CSV with address data, looks up each unique address via the BC Geocoder + location API to get coordinates, then queries the electoral district WFS endpoint. + Deduplicates addresses so identical Street+Street2+City combinations are only + looked up once. Handles rate limiting with exponential backoff. + Outputs a new CSV with all original columns plus the expected electoral district. + +.PARAMETER InputCsv + Path to the input CSV file. + +.PARAMETER OutputCsv + Path to the output CSV file. Defaults to _validated.csv. + +.PARAMETER GeocoderLocationBase + Base URL for the geocoder location API (GEOCODER_LOCATION_API_BASE). + Example: https://geocoder.api.gov.bc.ca + +.PARAMETER GeocoderApiBase + Base URL for the geocoder WFS API (GEOCODER_API_BASE). + Example: https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName= + +.PARAMETER InitialDelayMs + Initial delay between API calls in milliseconds. Default: 250. + +.EXAMPLE + .\Validate-ElectoralDistricts.ps1 ` + -InputCsv ".\data\electoral_districts.csv" ` + -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" ` + -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=" +#> +param( + [Parameter(Mandatory)] + [string]$InputCsv, + + [string]$OutputCsv = "", + + [Parameter(Mandatory)] + [string]$GeocoderLocationBase, + + [Parameter(Mandatory)] + [string]$GeocoderApiBase, + + [int]$InitialDelayMs = 100 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ── Output path defaults ────────────────────────────────────────────── +if (-not $OutputCsv) { + $dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv)) + $name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv) + $OutputCsv = Join-Path $dir "${name}_validated.csv" +} + +# ── Read CSV ────────────────────────────────────────────────────────── +$data = Import-Csv -Path $InputCsv +Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan + +# ── Helpers ─────────────────────────────────────────────────────────── +function Test-NullOrEmpty { + param([string]$value) + return [string]::IsNullOrWhiteSpace($value) -or $value.Trim() -eq 'NULL' +} + +function Get-CleanValue { + param([string]$value) + if (Test-NullOrEmpty $value) { return "" } + return $value.Trim() +} + +function Get-AddressKey { + param($row) + $street = Get-CleanValue $row.Street + $street2 = Get-CleanValue $row.Street2 + $city = Get-CleanValue $row.City + return "$street|$street2|$city".ToLowerInvariant() +} + +function Build-AddressString { + param($street, $street2, $city) + $parts = @($street, $street2, $city) | ForEach-Object { Get-CleanValue $_ } | Where-Object { $_ -ne "" } + return ($parts -join ", ") +} + +function Get-AddressTypeName { + param([string]$value) + $clean = Get-CleanValue $value + switch ($clean) { + "1" { return "Physical" } + "2" { return "Mailing" } + default { return "" } + } +} + +# ── Deduplicate addresses ──────────────────────────────────────────── +$addressLookup = [ordered]@{} +$skippedCount = 0 + +foreach ($row in $data) { + $street = Get-CleanValue $row.Street + $street2 = Get-CleanValue $row.Street2 + $city = Get-CleanValue $row.City + + # Skip rows where both street fields and city are empty/NULL + if ($street -eq "" -and $street2 -eq "" -and $city -eq "") { + $skippedCount++ + continue + } + + $key = Get-AddressKey $row + if (-not $addressLookup.Contains($key)) { + $addressLookup[$key] = @{ + Street = $row.Street + Street2 = $row.Street2 + City = $row.City + ExpectedED = $null + Score = $null + FullAddress = $null + Error = $null + } + } +} + +$uniqueCount = $addressLookup.Count +Write-Host "Unique addresses to look up: $uniqueCount (skipped $skippedCount rows with empty address)" -ForegroundColor Cyan + +# ── Rate-limited HTTP caller with exponential backoff ───────────────── +$script:currentDelayMs = $InitialDelayMs +$maxDelayMs = 30000 +$minDelayMs = 100 + +function Invoke-GeocoderRequest { + param( + [string]$Uri, + [int]$MaxRetries = 6 + ) + + for ($attempt = 1; $attempt -le ($MaxRetries + 1); $attempt++) { + try { + $response = Invoke-RestMethod -Uri $Uri -Method Get -ErrorAction Stop + + # On success, gently reduce the inter-call delay + $script:currentDelayMs = [Math]::Max($minDelayMs, [int]([Math]::Floor($script:currentDelayMs * 0.95))) + + return $response + } + catch { + $statusCode = 0 + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + + if (($statusCode -eq 429 -or $statusCode -eq 503) -and $attempt -le $MaxRetries) { + # Exponential backoff + $script:currentDelayMs = [Math]::Min($maxDelayMs, $script:currentDelayMs * 2) + Write-Host " Rate limited (HTTP $statusCode). Backing off $($script:currentDelayMs)ms (attempt $attempt/$MaxRetries)..." -ForegroundColor Yellow + Start-Sleep -Milliseconds $script:currentDelayMs + } + else { + throw + } + } + } +} + +# ── Process each unique address ─────────────────────────────────────── +$processed = 0 +$errorCount = 0 + +# Electoral district WFS parameters (from appsettings.json Geocoder:ElectoralDistrict) +$edFeature = "pub:WHSE_ADMIN_BOUNDARIES.EBC_PROV_ELECTORAL_DIST_SVW" +$edProperty = "ED_NAME" +$edQueryType = "SHAPE" + +foreach ($entry in $addressLookup.GetEnumerator()) { + $processed++ + $addr = $entry.Value + $addressString = Build-AddressString $addr.Street $addr.Street2 $addr.City + + Write-Host "[$processed/$uniqueCount] $addressString" -ForegroundColor Cyan + + try { + # Step 1: Geocode address → coordinates + $encodedAddress = [System.Uri]::EscapeDataString($addressString) + $locationUri = "$GeocoderLocationBase/addresses.json?outputSRS=3005&addressString=$encodedAddress" + + Start-Sleep -Milliseconds $script:currentDelayMs + $locationResult = Invoke-GeocoderRequest -Uri $locationUri + + if (-not $locationResult.features -or $locationResult.features.Count -eq 0) { + Write-Host " No location results" -ForegroundColor Yellow + $addr.Error = "No location results" + $errorCount++ + continue + } + + $coords = $locationResult.features[0].geometry.coordinates + $coordX = $coords[0] # EPSG:3005 easting (mirrors C# ResultMapper: coordinates[0]) + $coordY = $coords[1] # EPSG:3005 northing (mirrors C# ResultMapper: coordinates[1]) + $score = $locationResult.features[0].properties.score + $fullAddr = $locationResult.features[0].properties.fullAddress + + $addr.Score = $score + $addr.FullAddress = $fullAddr + Write-Host " Resolved: $fullAddr (score: $score)" -ForegroundColor DarkGray + + # Step 2: Look up electoral district from coordinates + $edUri = "${GeocoderApiBase}${edFeature}" + + "&srsname=EPSG:3005" + + "&propertyName=${edProperty}" + + "&outputFormat=application/json" + + "&cql_filter=INTERSECTS(${edQueryType},POINT($coordX $coordY))" + + Start-Sleep -Milliseconds $script:currentDelayMs + $edResult = Invoke-GeocoderRequest -Uri $edUri + + if (-not $edResult.features -or $edResult.features.Count -eq 0) { + Write-Host " No electoral district found for coordinates" -ForegroundColor Yellow + $addr.ExpectedED = "" + $addr.Error = "No electoral district for coordinates ($coordX, $coordY)" + $errorCount++ + continue + } + + $expectedED = $edResult.features[0].properties.ED_NAME + $addr.ExpectedED = $expectedED + Write-Host " Electoral District: $expectedED" -ForegroundColor Green + } + catch { + Write-Host " ERROR: $_" -ForegroundColor Red + $addr.Error = $_.ToString() + $errorCount++ + } +} + +# ── Build output CSV ────────────────────────────────────────────────── +Write-Host "`nBuilding output CSV..." -ForegroundColor Cyan + +$output = [System.Collections.Generic.List[PSCustomObject]]::new() + +foreach ($row in $data) { + $key = Get-AddressKey $row + + $expectedED = "" + $lookupError = "" + $geoScore = "" + $geoFullAddr = "" + $matchResult = "SKIPPED" + + if ($addressLookup.Contains($key)) { + $lookup = $addressLookup[$key] + $expectedED = if ($lookup.ExpectedED) { $lookup.ExpectedED } else { "" } + $lookupError = if ($lookup.Error) { $lookup.Error } else { "" } + $geoScore = if ($lookup.Score -ne $null) { $lookup.Score } else { "" } + $geoFullAddr = if ($lookup.FullAddress) { $lookup.FullAddress } else { "" } + + if ($expectedED -and $row.ApplicantElectoralDistrict) { + if ($expectedED.Trim() -eq $row.ApplicantElectoralDistrict.Trim()) { + $matchResult = "MATCH" + } + else { + $matchResult = "MISMATCH" + } + } + else { + $matchResult = "UNKNOWN" + } + } + + $outRow = [ordered]@{} + foreach ($prop in $row.PSObject.Properties) { + $outRow[$prop.Name] = $prop.Value + } + $outRow["AddressTypeName"] = Get-AddressTypeName $row.AddressType + $outRow["ExpectedElectoralDistrict"] = $expectedED + $outRow["DistrictMatch"] = $matchResult + $outRow["GeocoderScore"] = $geoScore + $outRow["GeocoderFullAddress"] = $geoFullAddr + $outRow["LookupError"] = $lookupError + + $output.Add([PSCustomObject]$outRow) +} + +$output | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8 + +# ── Summary ─────────────────────────────────────────────────────────── +$matchCount = @($output | Where-Object { $_.DistrictMatch -eq "MATCH" }).Count +$mismatchCount = @($output | Where-Object { $_.DistrictMatch -eq "MISMATCH" }).Count +$unknownCount = @($output | Where-Object { $_.DistrictMatch -eq "UNKNOWN" }).Count +$skippedRows = @($output | Where-Object { $_.DistrictMatch -eq "SKIPPED" }).Count + +Write-Host "" +Write-Host "=============================" -ForegroundColor White +Write-Host " Electoral District Audit" -ForegroundColor White +Write-Host "=============================" -ForegroundColor White +Write-Host "Total rows: $($data.Count)" +Write-Host "Unique addresses: $uniqueCount" +Write-Host "API errors: $errorCount" -ForegroundColor $(if ($errorCount -gt 0) { "Red" } else { "Green" }) +Write-Host "-----------------------------" +Write-Host "MATCH: $matchCount" -ForegroundColor Green +Write-Host "MISMATCH: $mismatchCount" -ForegroundColor $(if ($mismatchCount -gt 0) { "Red" } else { "Green" }) +Write-Host "UNKNOWN: $unknownCount" -ForegroundColor Yellow +Write-Host "SKIPPED: $skippedRows" -ForegroundColor DarkGray +Write-Host "-----------------------------" +Write-Host "Output: $OutputCsv" -ForegroundColor White diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs index f757747e49..f1d92723ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs @@ -91,12 +91,35 @@ public async Task DetermineElectoralDistrictAsync(Application? application, Appl } // Extract from geo services - var address = matchedAddressType.GetFullAddress(); + var address = matchedAddressType.GetSearchAddress(); var geoAddressDetails = await geocoderApiService.GetAddressDetailsAsync(address); if (geoAddressDetails == null || geoAddressDetails.Coordinates == null) { - logger.LogWarning("No coordinates found for address: {Address}", address); + logger.LogWarning("No coordinates found for address: {Address} for application {ApplicationId}.", + address, application.Id); + return; + } + + logger.LogInformation( + "Geocoder resolved address for application {ApplicationId}: " + + "Input={Address}, Resolved={ResolvedAddress}, Score={GeocoderScore}, " + + "Coordinates=({Latitude}, {Longitude})", + application.Id, + address, + geoAddressDetails.FullAddress, + geoAddressDetails.Score, + geoAddressDetails.Coordinates.Latitude, + geoAddressDetails.Coordinates.Longitude); + + if (geoAddressDetails.Score < 60) + { + application.ApplicantElectoralDistrict = null; + logger.LogWarning( + "Low geocoder confidence score {GeocoderScore} for application {ApplicationId}. " + + "Input={Address}, Resolved={ResolvedAddress}. " + + "Electoral district set to null due to unreliable geocoding.", + geoAddressDetails.Score, application.Id, address, geoAddressDetails.FullAddress); return; } @@ -105,12 +128,17 @@ public async Task DetermineElectoralDistrictAsync(Application? application, Appl if (electoralDistrict.Name != null) { application.ApplicantElectoralDistrict = electoralDistrict.Name; - logger.LogInformation("Electoral district '{ElectoralDistrict}' determined for address: {Address}", - electoralDistrict.Name, address); + logger.LogInformation( + "Electoral district '{ElectoralDistrict}' determined for application {ApplicationId}. " + + "Address={Address}, GeocoderScore={GeocoderScore}", + electoralDistrict.Name, application.Id, address, geoAddressDetails.Score); } else { - logger.LogWarning("Electoral district could not be determined for address: {Address}", address); + logger.LogWarning( + "Electoral district could not be determined for application {ApplicationId}. " + + "Address={Address}, GeocoderScore={GeocoderScore}", + application.Id, address, geoAddressDetails.Score); } } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs index f82c83afa4..d06415e6ca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs @@ -42,19 +42,15 @@ public virtual Application Application public Guid? TenantId { get; set; } /// - /// Returns the address as a single comma-separated string, ordered by relevance. + /// Returns a search-friendly address string (Street, Street2, City) for geocoding lookups. /// - public string GetFullAddress() + public string GetSearchAddress() { var parts = new[] { Street, Street2, - Unit, - City, - Province, - Postal, - Country + City }; var address = string.Join(", ", parts From d57c0c8ceb4861b6bf48c8bb913c1643bfc335ff Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 17 Apr 2026 10:56:47 -0700 Subject: [PATCH 02/47] AB#32694 update applicant profile contact handling --- .../UpdateApplicantContactAddressesDto.cs | 1 + .../Applicants/ApplicantAppService.cs | 44 ++++++ .../Handlers/ContactCreateHandler.cs | 23 --- .../Handlers/ContactEditHandler.cs | 72 +++++---- .../ApplicantContactsViewComponent.cs | 146 +++++++++++++----- .../ApplicantContactsViewModel.cs | 3 + .../ApplicantContacts/Default.cshtml | 1 + .../Components/ApplicantContacts/Default.js | 9 ++ .../GrantsPortal/ContactCreateHandlerTests.cs | 19 --- 9 files changed, 208 insertions(+), 110 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/UpdateApplicantContactAddressesDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/UpdateApplicantContactAddressesDto.cs index 523fbce1a1..8ddb5b4d81 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/UpdateApplicantContactAddressesDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/UpdateApplicantContactAddressesDto.cs @@ -12,6 +12,7 @@ public class UpdateApplicantContactAddressesDto public class UpdatePrimaryContactDto { public Guid Id { get; set; } + public string? Source { get; set; } public string? FullName { get; set; } public string? Title { get; set; } public string? Email { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs index 73c468aa06..cd7469644c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs @@ -262,6 +262,20 @@ private async Task UpdatePrimaryContactAsync(Guid applicantId, UpdatePrimaryCont throw new ArgumentException("Contact identifier is required.", nameof(input)); } + switch (input.Source) + { + case "Contact": + await UpdateLinkedContactAsync(applicantId, input); + break; + case "Agent": + default: + await UpdateAgentContactAsync(applicantId, input); + break; + } + } + + private async Task UpdateAgentContactAsync(Guid applicantId, UpdatePrimaryContactDto input) + { var applicantAgent = await applicantAgentRepository.GetAsync(input.Id); if (applicantAgent.ApplicantId != applicantId) { @@ -279,6 +293,36 @@ private async Task UpdatePrimaryContactAsync(Guid applicantId, UpdatePrimaryCont await applicantAgentRepository.UpdateAsync(applicantAgent); } + private async Task UpdateLinkedContactAsync(Guid applicantId, UpdatePrimaryContactDto input) + { + var contactRepository = LazyServiceProvider.LazyGetRequiredService(); + var contactLinkRepository = LazyServiceProvider.LazyGetRequiredService(); + + var linkQuery = await contactLinkRepository.GetQueryableAsync(); + var link = await linkQuery.FirstOrDefaultAsync(l => + l.ContactId == input.Id + && l.RelatedEntityType == "Applicant" + && l.RelatedEntityId == applicantId + && l.IsActive); + + if (link == null) + { + throw new BusinessException("Unity:Applicant:ContactNotFound") + .WithData("ApplicantId", applicantId) + .WithData("ContactId", input.Id); + } + + var contact = await contactRepository.GetAsync(input.Id); + + contact.Name = input.FullName?.Trim() ?? string.Empty; + contact.Title = input.Title?.Trim(); + contact.Email = input.Email?.Trim(); + contact.WorkPhoneNumber = input.BusinessPhone?.Trim(); + contact.MobilePhoneNumber = input.CellPhone?.Trim(); + + await contactRepository.UpdateAsync(contact); + } + private async Task UpdatePrimaryAddressAsync(Guid applicantId, UpdatePrimaryApplicantAddressDto input, GrantApplications.AddressType expectedType) { if (input.Id == Guid.Empty) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs index 769e4b1135..973a64d8c7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -83,28 +83,5 @@ public virtual async Task HandleAsync(PluginDataPayload payload) logger.LogInformation("Contact {ContactId} created successfully", contactId); return "Contact created successfully"; - } - - /// - /// Normalizes a raw OIDC subject by stripping the IDP suffix (after @) and uppercasing. - /// This matches the format stored in ApplicationFormSubmission.OidcSub. - /// - internal static string? NormalizeOidcSub(string? subject) - { - if (string.IsNullOrWhiteSpace(subject)) - { - return null; - } - - var atIndex = subject.IndexOf('@'); - - if (atIndex == 0) - { - return null; - } - - return atIndex > 0 - ? subject[..atIndex].ToUpperInvariant() - : subject.ToUpperInvariant(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs index 856821d7f7..ba33ecce29 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -33,50 +34,55 @@ public virtual async Task HandleAsync(PluginDataPayload payload) logger.LogInformation("Editing contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + await UpdateContactAsync(contactId, innerData); + await SyncContactLinkAsync(contactId, innerData); + + logger.LogInformation("Contact {ContactId} updated successfully", contactId); + return "Contact updated successfully"; + } + + private async Task UpdateContactAsync(Guid contactId, ContactEditData data) + { var contact = await contactRepository.GetAsync(contactId); - contact.Name = innerData.Name; - contact.Email = innerData.Email; - contact.Title = innerData.Title; - contact.HomePhoneNumber = innerData.HomePhoneNumber; - contact.MobilePhoneNumber = innerData.MobilePhoneNumber; - contact.WorkPhoneNumber = innerData.WorkPhoneNumber; - contact.WorkPhoneExtension = innerData.WorkPhoneExtension; + contact.Name = data.Name; + contact.Email = data.Email; + contact.Title = data.Title; + contact.HomePhoneNumber = data.HomePhoneNumber; + contact.MobilePhoneNumber = data.MobilePhoneNumber; + contact.WorkPhoneNumber = data.WorkPhoneNumber; + contact.WorkPhoneExtension = data.WorkPhoneExtension; - // Sync contact-link primary flags to match the incoming value + await contactRepository.UpdateAsync(contact); + } + + private async Task SyncContactLinkAsync(Guid contactId, ContactEditData data) + { var contactLinks = await contactLinkRepository.GetListAsync( cl => cl.RelatedEntityType == ApplicantEntityType - && cl.RelatedEntityId == innerData.ApplicantId + && cl.RelatedEntityId == data.ApplicantId && cl.IsActive); - if (innerData.IsPrimary) + if (data.IsPrimary) { - foreach (var stale in contactLinks.Where(cl => cl.IsPrimary && cl.ContactId != contactId)) - { - stale.IsPrimary = false; - await contactLinkRepository.UpdateAsync(stale); - } - - var newPrimary = contactLinks.FirstOrDefault(cl => cl.ContactId == contactId && !cl.IsPrimary); - if (newPrimary != null) - { - newPrimary.IsPrimary = true; - await contactLinkRepository.UpdateAsync(newPrimary); - } + await DemoteOtherPrimaryLinksAsync(contactLinks, contactId); } - else + + var targetLink = contactLinks.FirstOrDefault(cl => cl.ContactId == contactId); + if (targetLink != null) { - var demoted = contactLinks.FirstOrDefault(cl => cl.ContactId == contactId && cl.IsPrimary); - if (demoted != null) - { - demoted.IsPrimary = false; - await contactLinkRepository.UpdateAsync(demoted); - } + targetLink.IsPrimary = data.IsPrimary; + targetLink.Role = data.Role; + await contactLinkRepository.UpdateAsync(targetLink); } + } - await contactRepository.UpdateAsync(contact); - - logger.LogInformation("Contact {ContactId} updated successfully", contactId); - return "Contact updated successfully"; + private async Task DemoteOtherPrimaryLinksAsync(List contactLinks, Guid contactId) + { + foreach (var stale in contactLinks.Where(cl => cl.IsPrimary && cl.ContactId != contactId)) + { + stale.IsPrimary = false; + await contactLinkRepository.UpdateAsync(stale); + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs index 1c529a9f1d..6300519eca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Unity.GrantManager.Applications; +using Unity.GrantManager.Contacts; using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; @@ -20,18 +22,26 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts AutoInitialize = true)] public class ApplicantContactsViewComponent : AbpViewComponent { + private const string ApplicantEntityType = "Applicant"; + private readonly IApplicantAgentRepository _applicantAgentRepository; private readonly IPermissionChecker _permissionChecker; private readonly IRepository _applicationRepository; + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; public ApplicantContactsViewComponent( IApplicantAgentRepository applicantAgentRepository, IPermissionChecker permissionChecker, - IRepository applicationRepository) + IRepository applicationRepository, + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository) { _applicantAgentRepository = applicantAgentRepository; _permissionChecker = permissionChecker; _applicationRepository = applicationRepository; + _contactRepository = contactRepository; + _contactLinkRepository = contactLinkRepository; } public async Task InvokeAsync(Guid applicantId) @@ -42,62 +52,128 @@ public async Task InvokeAsync(Guid applicantId) } var agents = await _applicantAgentRepository.GetListByApplicantIdAsync(applicantId); - var orderedAgents = agents .OrderByDescending(a => a.LastModificationTime ?? a.CreationTime) .ToList(); - var appIds = new HashSet( - orderedAgents.Where(a => a.ApplicationId.HasValue).Select(a => a.ApplicationId!.Value)); + var appRefMap = await BuildApplicationReferenceMapAsync(orderedAgents); + var linkedContacts = await GetLinkedContactsAsync(applicantId); + var agentContacts = MapAgentContacts(orderedAgents, appRefMap); - var appRefMap = new Dictionary(); - if (appIds.Count > 0) - { - var apps = await _applicationRepository.GetListAsync(a => appIds.Contains(a.Id)); - foreach (var app in apps) - { - appRefMap[app.Id] = app.ReferenceNo; - } - } + var allContacts = agentContacts.Concat(linkedContacts) + .OrderByDescending(c => c.CreationTime) + .ToList(); + + ResolvePrimaryContact(allContacts); var viewModel = new ApplicantContactsViewModel { ApplicantId = applicantId, CanEditContact = await _permissionChecker.IsGrantedAsync(UnitySelector.Applicant.Contact.Update), - Contacts = orderedAgents - .Select((agent, index) => new ApplicantContactItemDto - { - Id = agent.Id, - Name = agent.Name ?? string.Empty, - Email = agent.Email ?? string.Empty, - Phone = !string.IsNullOrWhiteSpace(agent.Phone) - ? agent.Phone! - : agent.Phone2 ?? string.Empty, - Title = agent.Title ?? string.Empty, - Type = index == 0 ? "Primary" : "", - CreationTime = agent.CreationTime, - ApplicationId = agent.ApplicationId, - ReferenceNo = agent.ApplicationId.HasValue ? appRefMap.GetValueOrDefault(agent.ApplicationId.Value, string.Empty) : string.Empty - }) - .ToList() + Contacts = allContacts }; - var primaryContact = orderedAgents.FirstOrDefault(); + var primaryContact = allContacts.FirstOrDefault(c => c.IsPrimary); if (primaryContact != null) { viewModel.PrimaryContact = new ApplicantPrimaryContactViewModel { Id = primaryContact.Id, - FullName = primaryContact.Name ?? string.Empty, - Title = primaryContact.Title ?? string.Empty, - Email = primaryContact.Email ?? string.Empty, - BusinessPhone = primaryContact.Phone ?? string.Empty, - CellPhone = primaryContact.Phone2 ?? string.Empty + Source = primaryContact.Source, + FullName = primaryContact.Name, + Title = primaryContact.Title, + Email = primaryContact.Email, + BusinessPhone = primaryContact.Phone, + CellPhone = string.Empty }; } return View(viewModel); } + + private async Task> BuildApplicationReferenceMapAsync(List agents) + { + var appIds = new HashSet( + agents.Where(a => a.ApplicationId.HasValue).Select(a => a.ApplicationId!.Value)); + + var appRefMap = new Dictionary(); + if (appIds.Count > 0) + { + var apps = await _applicationRepository.GetListAsync(a => appIds.Contains(a.Id)); + foreach (var app in apps) + { + appRefMap[app.Id] = app.ReferenceNo; + } + } + + return appRefMap; + } + + private static List MapAgentContacts( + List agents, + Dictionary appRefMap) + { + return agents + .Select(agent => new ApplicantContactItemDto + { + Id = agent.Id, + Name = agent.Name ?? string.Empty, + Email = agent.Email ?? string.Empty, + Phone = !string.IsNullOrWhiteSpace(agent.Phone) + ? agent.Phone! + : agent.Phone2 ?? string.Empty, + Title = agent.Title ?? string.Empty, + Type = string.Empty, + Source = "Agent", + IsPrimary = false, + CreationTime = agent.CreationTime, + ApplicationId = agent.ApplicationId, + ReferenceNo = agent.ApplicationId.HasValue + ? appRefMap.GetValueOrDefault(agent.ApplicationId.Value, string.Empty) + : string.Empty + }) + .ToList(); + } + + private static void ResolvePrimaryContact(List contacts) + { + var primaryContact = contacts.FirstOrDefault(c => c.IsPrimary) + ?? contacts.FirstOrDefault(); + + if (primaryContact != null) + { + primaryContact.IsPrimary = true; + } + } + + private async Task> GetLinkedContactsAsync(Guid applicantId) + { + var contactLinksQuery = await _contactLinkRepository.GetQueryableAsync(); + var contactsQuery = await _contactRepository.GetQueryableAsync(); + + return await ( + from link in contactLinksQuery + join contact in contactsQuery on link.ContactId equals contact.Id + where link.RelatedEntityType == ApplicantEntityType + && link.RelatedEntityId == applicantId + && link.IsActive + select new ApplicantContactItemDto + { + Id = contact.Id, + Name = contact.Name, + Email = contact.Email ?? string.Empty, + Phone = !string.IsNullOrWhiteSpace(contact.WorkPhoneNumber) + ? contact.WorkPhoneNumber! + : contact.MobilePhoneNumber ?? string.Empty, + Title = contact.Title ?? string.Empty, + Type = link.Role ?? string.Empty, + Source = "Contact", + IsPrimary = link.IsPrimary, + CreationTime = contact.CreationTime, + ApplicationId = null, + ReferenceNo = string.Empty + }).ToListAsync(); + } } public class ApplicantContactsStyleBundleContributor : BundleContributor diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs index 995f62aaef..74bb3b2916 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs @@ -16,6 +16,7 @@ public class ApplicantContactsViewModel public class ApplicantPrimaryContactViewModel { public Guid Id { get; set; } + public string Source { get; set; } = string.Empty; [Display(Name = "Full Name")] public string FullName { get; set; } = string.Empty; [Display(Name = "Title")] @@ -37,6 +38,8 @@ public class ApplicantContactItemDto public string Phone { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public bool IsPrimary { get; set; } public DateTime CreationTime { get; set; } public string ReferenceNo { get; set; } = string.Empty; public Guid? ApplicationId { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml index cc1620fa2d..f29c93bec3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml @@ -19,6 +19,7 @@
+ @if (Model.CanSave) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js index de2d963e45..c784527fee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js @@ -80,6 +80,12 @@ $(function () { width: '10%', render: (data) => data || nullPlaceholder }, + { + title: 'Primary', + data: 'isPrimary', + width: '8%', + render: (data) => data ? 'Yes' : '' + }, { title: 'Submission #', data: 'referenceNo', @@ -129,9 +135,12 @@ $(function () { return; } + const contactSource = $('#ApplicantContacts_PrimaryContactSource').val(); + const payload = { primaryContact: { id: contactId, + source: contactSource, fullName: form.find('[name="PrimaryContact.FullName"]').val(), title: form.find('[name="PrimaryContact.Title"]').val(), email: form.find('[name="PrimaryContact.Email"]').val(), diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs index 852816cc56..28b0ce3b8a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs @@ -277,23 +277,4 @@ public async Task HandleAsync_WhenDataMissing_ShouldThrow() } #endregion - - #region NormalizeOidcSub - - [Theory] - [InlineData("testuser@idir", "TESTUSER")] - [InlineData("abc@bceidbusiness", "ABC")] - [InlineData("ALREADY", "ALREADY")] - [InlineData("mixedCase", "MIXEDCASE")] - [InlineData("user@", "USER")] - [InlineData(null, null)] - [InlineData("", null)] - [InlineData(" ", null)] - [InlineData("@idir", null)] - public void NormalizeOidcSub_ShouldStripIdpSuffixAndUppercase(string? input, string? expected) - { - ContactCreateHandler.NormalizeOidcSub(input).ShouldBe(expected); } - - #endregion -} From 0c9a3e39df0507598bf2356fb986478601c55691 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 22 Apr 2026 18:48:36 -0700 Subject: [PATCH 03/47] AB#32694 applicant profile contacts alignment and refactor --- .../Contacts/ApplicantContactRoleOptions.cs | 25 + .../Contacts/IApplicantContactAppService.cs | 38 ++ .../IApplicantContactQueryService.cs} | 17 +- .../Contacts/UpdateApplicantContactDto.cs | 42 ++ .../IApplicantProfileDataProvider.cs | 0 .../ProfileData/ContactInfoItemDto.cs | 1 + .../{ => Queries}/ApplicantProfileDto.cs | 0 .../{ => Queries}/ApplicantProfileRequest.cs | 0 .../IApplicantProfileQueryService.cs} | 9 +- .../Contacts/IContactAppService.cs | 13 + .../Contacts/UpdateContactDto.cs | 35 ++ ...ApplicationPermissionDefinitionProvider.cs | 1 + .../AppServices/ApplicantContactAppService.cs | 73 +++ .../ApplicantProfileAppService.cs | 213 --------- .../ApplicantProfileContactService.cs | 145 ------ .../AddressInfoDataProvider.cs | 0 .../ContactInfoDataProvider.cs | 9 +- .../OrgInfoDataProvider.cs | 0 .../PaymentInfoDataProvider.cs | 0 .../SubmissionInfoDataProvider.cs | 0 .../Queries/ApplicantContactQueryService.cs | 324 +++++++++++++ .../Queries/ApplicantProfileQueryService.cs | 114 +++++ .../ApplicantTenantMapReconciler.cs | 121 +++++ .../IApplicantTenantMapReconciler.cs | 19 + .../ApplicantTenantMapReconciliationWorker.cs | 10 +- .../Contacts/ContactAppService.cs | 122 +++-- .../Localization/GrantManager/en.json | 76 ++- .../Contacts/ContactInput.cs | 14 + .../Contacts/ContactManager.cs | 162 +++++++ .../Contacts/IContactManager.cs | 42 ++ .../Controllers/ApplicantProfileController.cs | 6 +- .../GrantManagerWebAutoMapperProfile.cs | 17 + .../ApplicantContactModalViewModel.cs | 72 +++ .../Pages/ApplicantContact/EditModal.cshtml | 45 ++ .../ApplicantContact/EditModal.cshtml.cs | 59 +++ .../ApplicantContactsController.cs | 1 - .../ApplicantContactsViewComponent.cs | 154 +------ .../ApplicantContactsViewModel.cs | 47 +- .../ApplicantContacts/Default.cshtml | 137 +++--- .../Components/ApplicantContacts/Default.css | 106 +++++ .../Components/ApplicantContacts/Default.js | 434 ++++++++++++------ .../ChefsAttachments/ChefsAttachments.cs | 44 +- .../ChefsAttachments/Default.cshtml | 4 +- .../ApplicantContactAppServiceTests.cs | 129 ++++++ .../ApplicantProfileDataProviderTests.cs | 10 +- .../ContactInfoDataProviderTests.cs | 48 +- ...licantContactQueryServiceApplicantTests.cs | 300 ++++++++++++ ...plicantContactQueryServiceSubjectTests.cs} | 8 +- .../ApplicantProfileQueryServiceTests.cs | 121 +++++ .../Contacts/ContactAppServiceTests.cs | 409 +++++------------ .../ApplicantContactsWidgetTests.cs | 78 ++++ 51 files changed, 2661 insertions(+), 1193 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/ApplicantContactRoleOptions.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/IApplicantContactAppService.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/{IApplicantProfileContactService.cs => Contacts/IApplicantContactQueryService.cs} (69%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/UpdateApplicantContactDto.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/{ => DataProviders}/IApplicantProfileDataProvider.cs (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/{ => Queries}/ApplicantProfileDto.cs (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/{ => Queries}/ApplicantProfileRequest.cs (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/{IApplicantProfileAppService.cs => Queries/IApplicantProfileQueryService.cs} (50%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/UpdateContactDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AppServices/ApplicantContactAppService.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/{ => DataProviders}/AddressInfoDataProvider.cs (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/{ => DataProviders}/ContactInfoDataProvider.cs (77%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/{ => DataProviders}/OrgInfoDataProvider.cs (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/{ => DataProviders}/PaymentInfoDataProvider.cs (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/{ => DataProviders}/SubmissionInfoDataProvider.cs (100%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantContactQueryService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactInput.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/IContactManager.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebAutoMapperProfile.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/ApplicantContactModalViewModel.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicantProfile/AppServices/ApplicantContactAppServiceTests.cs rename applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/{Applicants => ApplicantProfile/DataProviders}/ApplicantProfileDataProviderTests.cs (95%) rename applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/{Contacts => ApplicantProfile/DataProviders}/ContactInfoDataProviderTests.cs (78%) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicantProfile/Queries/ApplicantContactQueryServiceApplicantTests.cs rename applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/{Contacts/ContactInfoServiceTests.cs => ApplicantProfile/Queries/ApplicantContactQueryServiceSubjectTests.cs} (98%) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicantProfile/Queries/ApplicantProfileQueryServiceTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicantContactsWidgetTests.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/ApplicantContactRoleOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/ApplicantContactRoleOptions.cs new file mode 100644 index 0000000000..ecc917b600 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/ApplicantContactRoleOptions.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Canonical set of contact role/type options exposed to the internal +/// Applicant Contacts UI. Mirrors the options defined by the Applicant Portal. +/// +public static class ApplicantContactRoleOptions +{ + /// Machine key used when storing the role on . + public static readonly IReadOnlyList Options = + [ + new("General", "General"), + new("Primary", "Primary Contact"), + new("Financial", "Financial Officer"), + new("SigningAuthority", "Additional Signing Authority"), + new("Executive", "Executive") + ]; +} + +/// Applicant contact role option (code + human-readable label). +/// The role key stored in . +/// The label displayed in the UI. +public record ApplicantContactRoleOption(string Value, string Label); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/IApplicantContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/IApplicantContactAppService.cs new file mode 100644 index 0000000000..73bf5a52d8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/IApplicantContactAppService.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Contacts; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Authorized application service that exposes Create / Update / SetPrimary +/// operations for applicant-scoped contacts (those linked to the Applicant aggregate +/// via with RelatedEntityType = "Applicant"), +/// and aggregated read access that combines applicant-linked, application, and +/// applicant-agent contacts into a single view model. +/// +/// This is the HTTP-facing surface used by the internal Applicant Contacts widget. +/// The underlying remains unexposed so that existing +/// Applicant Portal message handlers (which write directly to the repositories) are +/// unaffected by authorization. +/// +/// +public interface IApplicantContactAppService : IApplicationService +{ + /// + /// Retrieves the aggregated contact info for the specified applicant. + /// + Task GetByApplicantIdAsync(Guid applicantId); + + /// + /// Updates an existing applicant-linked contact. + /// + Task UpdateAsync(Guid applicantId, Guid contactId, UpdateApplicantContactDto input); + + /// + /// Flags the specified contact as primary for the given applicant. + /// + Task SetPrimaryAsync(Guid applicantId, Guid contactId); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/IApplicantContactQueryService.cs similarity index 69% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/IApplicantContactQueryService.cs index 779b4f7148..c0e7c9b9c6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/IApplicantContactQueryService.cs @@ -7,11 +7,11 @@ namespace Unity.GrantManager.ApplicantProfile; /// /// Provides applicant-profile-specific contact retrieval operations. -/// This service aggregates contacts from three sources: profile-linked contacts, +/// This query service aggregates contacts from three sources: profile-linked contacts, /// application-level contacts matched by OIDC subject, and applicant agent /// contacts derived from the submission login token. /// -public interface IApplicantProfileContactService +public interface IApplicantContactQueryService { /// /// Retrieves contacts linked to the applicant profile by resolving applicant IDs from @@ -39,4 +39,17 @@ public interface IApplicantProfileContactService /// The OIDC subject identifier (e.g. "user@idir"). /// A list of with IsEditable set to false. Task> GetApplicantAgentContactsBySubjectAsync(string subject); + + /// + /// Retrieves the aggregated contact info for the specified applicant, combining + /// applicant-linked contacts ( with + /// RelatedEntityType = "Applicant"), application contacts, and applicant agent contacts + /// for every application owned by the applicant. Applicant-linked contacts are marked editable; + /// application and agent contacts are always read-only. The primary flag is resolved either from + /// an explicit IsPrimary contact link or, if none exists, by falling back to the most + /// recently created contact. + /// + /// The unique identifier of the applicant. + /// A populated . + Task GetByApplicantIdAsync(Guid applicantId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/UpdateApplicantContactDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/UpdateApplicantContactDto.cs new file mode 100644 index 0000000000..da092fda00 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Contacts/UpdateApplicantContactDto.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Input DTO for updating an existing applicant-scoped contact. +/// +public class UpdateApplicantContactDto +{ + /// Role/type key — see . + [Required] + [MaxLength(100)] + public string? Role { get; set; } + + /// Full name. + [Required] + [MinLength(2)] + [MaxLength(250)] + public string Name { get; set; } = string.Empty; + + /// Job title. + [MaxLength(200)] + public string? Title { get; set; } + + /// Email address. + [EmailAddress] + public string? Email { get; set; } + + /// Mobile phone number. + [RegularExpression(@"^[\+]?[0-9\-\.\(\)\s]*$")] + public string? MobilePhoneNumber { get; set; } + + /// Work phone number. + [RegularExpression(@"^[\+]?[0-9\-\.\(\)\s]*$")] + public string? WorkPhoneNumber { get; set; } + + /// Work phone extension. + public string? WorkPhoneExtension { get; set; } + + /// When true, other primary contact links for the applicant are demoted. + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/DataProviders/IApplicantProfileDataProvider.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/DataProviders/IApplicantProfileDataProvider.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs index ac0ec9b771..065223bd34 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs @@ -15,6 +15,7 @@ public class ContactInfoItemDto public string? ContactType { get; set; } public string? Role { get; set; } public bool IsPrimary { get; set; } + public bool IsPrimaryInferred { get; set; } public bool IsEditable { get; set; } public Guid? ApplicationId { get; set; } public string? ReferenceNo { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Queries/ApplicantProfileDto.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Queries/ApplicantProfileDto.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Queries/ApplicantProfileRequest.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Queries/ApplicantProfileRequest.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Queries/IApplicantProfileQueryService.cs similarity index 50% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Queries/IApplicantProfileQueryService.cs index feb3645823..b678390e7f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/Queries/IApplicantProfileQueryService.cs @@ -4,11 +4,14 @@ namespace Unity.GrantManager.ApplicantProfile { - public interface IApplicantProfileAppService + /// + /// Internal query service that aggregates applicant profile data and tenant mappings for the + /// ApplicantProfileController API surface. Not an ABP application service — the + /// controller handles routing, authorization and API exposure directly. + /// + public interface IApplicantProfileQueryService { Task GetApplicantProfileAsync(ApplicantProfileInfoRequest request); Task> GetApplicantTenantsAsync(ApplicantProfileRequest request); - Task<(int Created, int Updated)> ReconcileApplicantTenantMapsAsync(); } } - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs index 4a07057c5d..3aeee4c050 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs @@ -27,6 +27,19 @@ public interface IContactAppService /// The created . Task CreateContactAsync(CreateContactLinkDto input); + /// + /// Updates an existing contact and synchronizes the primary / role flags on its link + /// to the specified related entity. If is true, + /// any other active primary links for the same entity are demoted. + /// + /// The type of the related entity. + /// The unique identifier of the related entity. + /// The unique identifier of the contact to update. + /// The updated contact details. + /// The updated . + /// Thrown when no active contact link is found for the given parameters. + Task UpdateContactAsync(string entityType, Guid entityId, Guid contactId, UpdateContactDto input); + /// /// Sets the specified contact as the primary contact for the given entity. /// Only one primary contact is allowed per entity type and entity ID; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/UpdateContactDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/UpdateContactDto.cs new file mode 100644 index 0000000000..782c9562e7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/UpdateContactDto.cs @@ -0,0 +1,35 @@ +namespace Unity.GrantManager.Contacts; + +/// +/// Input DTO for updating an existing contact and (optionally) the primary/role flags +/// of its link to a related entity. +/// +public class UpdateContactDto +{ + /// The full name of the contact. + public string Name { get; set; } = string.Empty; + + /// The job title of the contact. + public string? Title { get; set; } + + /// The email address of the contact. + public string? Email { get; set; } + + /// The home phone number of the contact. + public string? HomePhoneNumber { get; set; } + + /// The mobile phone number of the contact. + public string? MobilePhoneNumber { get; set; } + + /// The work phone number of the contact. + public string? WorkPhoneNumber { get; set; } + + /// The work phone extension of the contact. + public string? WorkPhoneExtension { get; set; } + + /// The role of the contact within the linked entity context. + public string? Role { get; set; } + + /// Whether this contact should be flagged as the primary contact. When true, other primary links for the same entity are demoted. + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs index 5dae52cf3e..73236d3859 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs @@ -172,6 +172,7 @@ public static void AddApplication_ApplicantInfo_Permissions(this PermissionGroup var upx_Applicant_Summary_Update = upx_Applicant_Summary.AddUnityChild(UnitySelector.Applicant.Summary.Update); var upx_Applicant_Contact = upx_Applicant.AddUnityChild(UnitySelector.Applicant.Contact.Default); + var upx_Applicant_Contact_Create = upx_Applicant_Contact.AddUnityChild(UnitySelector.Applicant.Contact.Create); var upx_Applicant_Contact_Update = upx_Applicant_Contact.AddUnityChild(UnitySelector.Applicant.Contact.Update); var upx_Applicant_Authority = upx_Applicant.AddUnityChild(UnitySelector.Applicant.Authority.Default); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AppServices/ApplicantContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AppServices/ApplicantContactAppService.cs new file mode 100644 index 0000000000..a24bcd3391 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AppServices/ApplicantContactAppService.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Contacts; +using Unity.Modules.Shared; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Authorized facade over (reads) and +/// (writes) for the internal Applicant Contacts widget. +/// All writes are scoped to RelatedEntityType = "Applicant". +/// +[Authorize] +public class ApplicantContactAppService( + IApplicantContactQueryService applicantContactQueryService, + IContactManager contactManager) + : GrantManagerAppService, IApplicantContactAppService +{ + private const string ApplicantEntityType = "Applicant"; + + /// + [Authorize(UnitySelector.Applicant.Contact.Default)] + public virtual Task GetByApplicantIdAsync(Guid applicantId) + { + return applicantContactQueryService.GetByApplicantIdAsync(applicantId); + } + + /// + [Authorize(UnitySelector.Applicant.Contact.Update)] + public virtual async Task UpdateAsync(Guid applicantId, Guid contactId, UpdateApplicantContactDto input) + { + ArgumentNullException.ThrowIfNull(input); + + var (contact, link) = await contactManager.UpdateAsync( + ApplicantEntityType, + applicantId, + contactId, + new ContactInput( + input.Name, + input.Title, + input.Email, + HomePhoneNumber: null, + input.MobilePhoneNumber, + input.WorkPhoneNumber, + input.WorkPhoneExtension), + input.Role, + input.IsPrimary); + + return new ContactDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + Role = link.Role, + IsPrimary = link.IsPrimary + }; + } + + /// + [Authorize(UnitySelector.Applicant.Contact.Update)] + public virtual async Task SetPrimaryAsync(Guid applicantId, Guid contactId) + { + await contactManager.SetPrimaryAsync(ApplicantEntityType, applicantId, contactId); + return true; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs deleted file mode 100644 index bcb856bb16..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs +++ /dev/null @@ -1,213 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Unity.GrantManager.Applicants; -using Unity.GrantManager.Applications; -using Unity.Notifications.Settings; -using Volo.Abp; -using Volo.Abp.Application.Services; -using Volo.Abp.Domain.Repositories; -using Volo.Abp.MultiTenancy; -using Volo.Abp.Settings; -using Volo.Abp.TenantManagement; - -namespace Unity.GrantManager.ApplicantProfile -{ - [RemoteService(false)] - public class ApplicantProfileAppService( - ICurrentTenant currentTenant, - ITenantRepository tenantRepository, - IRepository applicantTenantMapRepository, - IRepository applicationFormSubmissionRepository, - IEnumerable dataProviders, - ISettingProvider settingProvider) - : ApplicationService, IApplicantProfileAppService - { - private readonly Dictionary _providersByKey - = dataProviders.ToDictionary(p => p.Key, StringComparer.OrdinalIgnoreCase); - - /// - /// Retrieves the applicant's profile information based on the specified request. - /// - /// An object containing the criteria used to identify the applicant profile to retrieve. Must not be null. - /// A task that represents the asynchronous operation. The task result contains an with the applicant's profile data. - public async Task GetApplicantProfileAsync(ApplicantProfileInfoRequest request) - { - var dto = new ApplicantProfileDto - { - ProfileId = request.ProfileId, - Subject = request.Subject, - TenantId = request.TenantId, - Key = request.Key - }; - - if (_providersByKey.TryGetValue(request.Key, out var provider)) - { - dto.Data = await provider.GetDataAsync(request); - } - else - { - Logger.LogWarning("Unknown applicant profile key provided"); - } - - return dto; - } - - /// - /// Retrieves a list of tenants associated with the specified applicant profile. - /// - /// The method extracts the username portion from the subject identifier in the request - /// to match tenant mappings. This operation is asynchronous and queries the host database for relevant tenant - /// associations. - /// An object containing applicant profile information, including the subject identifier used to locate tenant - /// mappings. - /// A list of objects representing the tenants linked to the applicant. The - /// list will be empty if no tenant associations are found. - public async Task> GetApplicantTenantsAsync(ApplicantProfileRequest request) - { - // Extract the username part from the OIDC sub (part before '@') - var subUsername = SubjectNormalizer.Normalize(request.Subject); - if (subUsername is null) return []; - List mappings = []; - - // Query the ApplicantTenantMaps table in the host database - using (currentTenant.Change(null)) - { - var queryable = await applicantTenantMapRepository.GetQueryableAsync(); - mappings = await queryable - .Where(m => m.OidcSubUsername == subUsername) - .Select(m => new ApplicantTenantDto - { - TenantId = m.TenantId, - TenantName = m.TenantName - }) - .ToListAsync(); - } - - // Apply tenant specific metadata - foreach (var map in mappings) - { - await AddTenantMetadataAsync(map); - } - - return mappings; - } - - /// - /// Add on any relevant tenant specific metadata - /// - /// The applicant tenant DTO to enrich with tenant-specific metadata. - private async Task AddTenantMetadataAsync(ApplicantTenantDto tenantMap) - { - using (currentTenant.Change(tenantMap.TenantId)) - { - var defaultEmailAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); - tenantMap.Metadata[ApplicantTenantMetadataKeys.DefaultFromAddress] = defaultEmailAddress ?? "NoReply@gov.bc.ca"; - } - } - - /// - /// Reconciles ApplicantTenantMaps by scanning all tenants for submissions - /// and ensuring mappings exist in the host database. - /// Phase 1: Collects all distinct OidcSub-to-tenant associations into memory. - /// Phase 2: Switches to host DB once and reconciles all mappings. - /// - /// Tuple of (created count, updated count) - public async Task<(int Created, int Updated)> ReconcileApplicantTenantMapsAsync() - { - Logger.LogInformation("Starting ApplicantTenantMap reconciliation..."); - - // Phase 1: Collect all OidcSub-to-tenant associations from each tenant DB - var desiredMappings = new List<(string SubUsername, Guid TenantId, string TenantName)>(); - var tenants = await tenantRepository.GetListAsync(); - - foreach (var tenant in tenants) - { - try - { - Logger.LogDebug("Collecting submissions from tenant: {TenantName}", tenant.Name); - - using (currentTenant.Change(tenant.Id)) - { - var submissionQueryable = await applicationFormSubmissionRepository.GetQueryableAsync(); - var distinctOidcSubs = await submissionQueryable - .Where(s => !string.IsNullOrWhiteSpace(s.OidcSub) && s.OidcSub != Guid.Empty.ToString()) - .Select(s => s.OidcSub) - .Distinct() - .ToListAsync(); - - foreach (var oidcSub in distinctOidcSubs) - { - var subUsername = oidcSub.Contains('@') - ? oidcSub[..oidcSub.IndexOf('@')].ToUpperInvariant() - : oidcSub.ToUpperInvariant(); - - desiredMappings.Add((subUsername, tenant.Id, tenant.Name)); - } - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error collecting submissions for tenant {TenantName}", tenant.Name); - } - } - - if (desiredMappings.Count == 0) - { - Logger.LogInformation("ApplicantTenantMap reconciliation completed. No submissions found across tenants."); - return (0, 0); - } - - // Phase 2: Switch to host DB once, load existing mappings, and reconcile - int totalMappingsCreated = 0; - int totalMappingsUpdated = 0; - - using (currentTenant.Change(null)) - { - var allSubUsernames = desiredMappings.Select(m => m.SubUsername).Distinct().ToList(); - - var mapQueryable = await applicantTenantMapRepository.GetQueryableAsync(); - var existingMappings = await mapQueryable - .Where(m => allSubUsernames.Contains(m.OidcSubUsername)) - .ToListAsync(); - - var existingByKey = existingMappings - .ToDictionary(m => (m.OidcSubUsername, m.TenantId)); - - foreach (var (subUsername, tenantId, tenantName) in desiredMappings) - { - if (existingByKey.TryGetValue((subUsername, tenantId), out var existing)) - { - existing.LastUpdated = DateTime.UtcNow; - await applicantTenantMapRepository.UpdateAsync(existing); - totalMappingsUpdated++; - } - else - { - var newMapping = new ApplicantTenantMap - { - OidcSubUsername = subUsername, - TenantId = tenantId, - TenantName = tenantName, - LastUpdated = DateTime.UtcNow - }; - await applicantTenantMapRepository.InsertAsync(newMapping); - existingByKey[(subUsername, tenantId)] = newMapping; - totalMappingsCreated++; - Logger.LogInformation("Created missing ApplicantTenantMap for {SubUsername} in tenant {TenantName}", - subUsername, tenantName); - } - } - } - - Logger.LogInformation("ApplicantTenantMap reconciliation completed. Created: {Created}, Updated: {Updated}", - totalMappingsCreated, totalMappingsUpdated); - - return (totalMappingsCreated, totalMappingsUpdated); - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs deleted file mode 100644 index 06dd26366d..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Unity.GrantManager.ApplicantProfile.ProfileData; -using Unity.GrantManager.Applications; -using Unity.GrantManager.Contacts; -using Unity.GrantManager.GrantApplications; -using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; - -namespace Unity.GrantManager.ApplicantProfile; - -/// -/// Applicant-profile-specific contact service. Retrieves contacts linked to applicant profiles, -/// application-level contacts matched by OIDC subject, and applicant agent contacts derived from -/// the submission login token. Profile contacts are resolved by looking up form submissions that -/// match the OIDC subject to obtain applicant IDs, then querying -/// records against those IDs. When a single applicant ID is resolved the contacts are editable; -/// when multiple IDs are found the contacts are read-only. This service operates independently from the -/// generic and queries repositories directly. -/// -public class ApplicantProfileContactService( - IContactRepository contactRepository, - IContactLinkRepository contactLinkRepository, - IRepository applicationFormSubmissionRepository, - IRepository applicationContactRepository, - IRepository applicantAgentRepository, - IRepository applicationRepository) - : IApplicantProfileContactService, ITransientDependency -{ - private const string ApplicantEntityType = "Applicant"; - - /// - public async Task> GetApplicantContactsAsync(string subject) - { - var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); - var contactsQuery = await contactRepository.GetQueryableAsync(); - var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); - - var applicantIds = await submissionsQuery - .Where(s => s.OidcSub == subject) - .Select(s => s.ApplicantId) - .Distinct() - .ToListAsync(); - - var isEditable = applicantIds.Count <= 1; - - return await ( - from link in contactLinksQuery - join contact in contactsQuery on link.ContactId equals contact.Id - where link.RelatedEntityType == ApplicantEntityType - && applicantIds.Contains(link.RelatedEntityId) - && link.IsActive - select new ContactInfoItemDto - { - ContactId = contact.Id, - Name = contact.Name, - Title = contact.Title, - Email = contact.Email, - HomePhoneNumber = contact.HomePhoneNumber, - MobilePhoneNumber = contact.MobilePhoneNumber, - WorkPhoneNumber = contact.WorkPhoneNumber, - WorkPhoneExtension = contact.WorkPhoneExtension, - ContactType = link.RelatedEntityType, - Role = link.Role, - IsPrimary = link.IsPrimary, - IsEditable = isEditable, - ReferenceNo = null, - CreationTime = contact.CreationTime - }).ToListAsync(); - } - - /// - public async Task> GetApplicationContactsBySubjectAsync(string subject) - { - var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); - var applicationContactsQuery = await applicationContactRepository.GetQueryableAsync(); - var applicationsQuery = await applicationRepository.GetQueryableAsync(); - - var applicationContacts = await ( - from submission in submissionsQuery - join appContact in applicationContactsQuery on submission.ApplicationId equals appContact.ApplicationId - join application in applicationsQuery on submission.ApplicationId equals application.Id - where submission.OidcSub == subject - select new ContactInfoItemDto - { - ContactId = appContact.Id, - Name = appContact.ContactFullName, - Title = appContact.ContactTitle, - Email = appContact.ContactEmail, - MobilePhoneNumber = appContact.ContactMobilePhone, - WorkPhoneNumber = appContact.ContactWorkPhone, - Role = GetMatchingRole(appContact.ContactType), - ContactType = "Application", - IsPrimary = false, - IsEditable = false, - ApplicationId = appContact.ApplicationId, - ReferenceNo = application.ReferenceNo, - CreationTime = appContact.CreationTime - }).ToListAsync(); - - return applicationContacts; - } - - /// - public async Task> GetApplicantAgentContactsBySubjectAsync(string subject) - { - var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); - var agentsQuery = await applicantAgentRepository.GetQueryableAsync(); - var applicationsQuery = await applicationRepository.GetQueryableAsync(); - - var agentContacts = await ( - from submission in submissionsQuery - join agent in agentsQuery on submission.ApplicationId equals agent.ApplicationId - join application in applicationsQuery on submission.ApplicationId equals application.Id - where submission.OidcSub == subject - select new ContactInfoItemDto - { - ContactId = agent.Id, - Name = agent.Name, - Title = agent.Title, - Email = agent.Email, - WorkPhoneNumber = agent.Phone, - WorkPhoneExtension = agent.PhoneExtension, - MobilePhoneNumber = agent.Phone2, - Role = agent.RoleForApplicant, - ContactType = "ApplicantAgent", - IsPrimary = false, - IsEditable = false, - ApplicationId = agent.ApplicationId, - ReferenceNo = application.ReferenceNo, - CreationTime = agent.CreationTime - }).ToListAsync(); - - return agentContacts; - } - - private static string GetMatchingRole(string contactType) - { - return ApplicationContactOptionList.ContactTypeList.TryGetValue(contactType, out string? value) - ? value : contactType; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/AddressInfoDataProvider.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/AddressInfoDataProvider.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/ContactInfoDataProvider.cs similarity index 77% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/ContactInfoDataProvider.cs index 8b7cbe547d..40253327a4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/ContactInfoDataProvider.cs @@ -13,7 +13,7 @@ namespace Unity.GrantManager.ApplicantProfile [ExposeServices(typeof(IApplicantProfileDataProvider))] public class ContactInfoDataProvider( ICurrentTenant currentTenant, - IApplicantProfileContactService applicantProfileContactService) + IApplicantContactQueryService applicantContactQueryService) : IApplicantProfileDataProvider, ITransientDependency { /// @@ -34,13 +34,13 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ using (currentTenant.Change(tenantId)) { - var applicantContacts = await applicantProfileContactService.GetApplicantContactsAsync(normalizedSubject); + var applicantContacts = await applicantContactQueryService.GetApplicantContactsAsync(normalizedSubject); dto.Contacts.AddRange(applicantContacts); - var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(normalizedSubject); + var applicationContacts = await applicantContactQueryService.GetApplicationContactsBySubjectAsync(normalizedSubject); dto.Contacts.AddRange(applicationContacts); - var agentContacts = await applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync(normalizedSubject); + var agentContacts = await applicantContactQueryService.GetApplicantAgentContactsBySubjectAsync(normalizedSubject); dto.Contacts.AddRange(agentContacts); } @@ -50,6 +50,7 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ .OrderByDescending(c => c.CreationTime) .First(); latest.IsPrimary = true; + latest.IsPrimaryInferred = true; } return dto; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/OrgInfoDataProvider.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/OrgInfoDataProvider.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/PaymentInfoDataProvider.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/PaymentInfoDataProvider.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/SubmissionInfoDataProvider.cs similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/SubmissionInfoDataProvider.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantContactQueryService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantContactQueryService.cs new file mode 100644 index 0000000000..c06fedebe1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantContactQueryService.cs @@ -0,0 +1,324 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Applicant-profile-specific contact query service. Retrieves contacts linked to applicant profiles, +/// application-level contacts matched by OIDC subject, and applicant agent contacts derived from +/// the submission login token. Profile contacts are resolved by looking up form submissions that +/// match the OIDC subject to obtain applicant IDs, then querying +/// records against those IDs. When a single applicant ID is resolved the contacts are editable; +/// when multiple IDs are found the contacts are read-only. This service operates independently from the +/// generic and queries repositories directly. +/// +public class ApplicantContactQueryService( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + IRepository applicationFormSubmissionRepository, + IRepository applicationContactRepository, + IRepository applicantAgentRepository, + IRepository applicationRepository) + : IApplicantContactQueryService, ITransientDependency +{ + private const string ApplicantEntityType = "Applicant"; + + /// + public async Task> GetApplicantContactsAsync(string subject) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var contactsQuery = await contactRepository.GetQueryableAsync(); + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + + var applicantIds = await submissionsQuery + .Where(s => s.OidcSub == subject) + .Select(s => s.ApplicantId) + .Distinct() + .ToListAsync(); + + var isEditable = applicantIds.Count <= 1; + + return await ( + from link in contactLinksQuery + join contact in contactsQuery on link.ContactId equals contact.Id + where link.RelatedEntityType == ApplicantEntityType + && applicantIds.Contains(link.RelatedEntityId) + && link.IsActive + select new ContactInfoItemDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + ContactType = link.RelatedEntityType, + Role = link.Role, + IsPrimary = link.IsPrimary, + IsEditable = isEditable, + ReferenceNo = null, + CreationTime = contact.CreationTime + }).ToListAsync(); + } + + /// + public async Task> GetApplicationContactsBySubjectAsync(string subject) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicationContactsQuery = await applicationContactRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var applicationContacts = await ( + from submission in submissionsQuery + join appContact in applicationContactsQuery on submission.ApplicationId equals appContact.ApplicationId + join application in applicationsQuery on submission.ApplicationId equals application.Id + where submission.OidcSub == subject + select new ContactInfoItemDto + { + ContactId = appContact.Id, + Name = appContact.ContactFullName, + Title = appContact.ContactTitle, + Email = appContact.ContactEmail, + MobilePhoneNumber = appContact.ContactMobilePhone, + WorkPhoneNumber = appContact.ContactWorkPhone, + Role = GetMatchingRole(appContact.ContactType), + ContactType = "Application", + IsPrimary = false, + IsEditable = false, + ApplicationId = appContact.ApplicationId, + ReferenceNo = application.ReferenceNo, + CreationTime = appContact.CreationTime + }).ToListAsync(); + + return applicationContacts; + } + + /// + public async Task> GetApplicantAgentContactsBySubjectAsync(string subject) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var agentsQuery = await applicantAgentRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var agentContacts = await ( + from submission in submissionsQuery + join agent in agentsQuery on submission.ApplicationId equals agent.ApplicationId + join application in applicationsQuery on submission.ApplicationId equals application.Id + where submission.OidcSub == subject + select new ContactInfoItemDto + { + ContactId = agent.Id, + Name = agent.Name, + Title = agent.Title, + Email = agent.Email, + WorkPhoneNumber = agent.Phone, + WorkPhoneExtension = agent.PhoneExtension, + MobilePhoneNumber = agent.Phone2, + Role = agent.RoleForApplicant, + ContactType = "ApplicantAgent", + IsPrimary = false, + IsEditable = false, + ApplicationId = agent.ApplicationId, + ReferenceNo = application.ReferenceNo, + CreationTime = agent.CreationTime + }).ToListAsync(); + + return agentContacts; + } + + /// + public async Task GetByApplicantIdAsync(Guid applicantId) + { + var dto = new ApplicantContactInfoDto { Contacts = [] }; + if (applicantId == Guid.Empty) + { + return dto; + } + + dto.Contacts.AddRange(await GetApplicantLinkedContactsAsync([applicantId], isEditable: true)); + dto.Contacts.AddRange(await GetApplicationContactsByApplicantIdAsync(applicantId)); + dto.Contacts.AddRange(await GetApplicantAgentContactsByApplicantIdAsync(applicantId)); + + ResolvePrimary(dto.Contacts); + + return dto; + } + + private async Task> GetApplicantLinkedContactsAsync( + IReadOnlyCollection applicantIds, + bool isEditable) + { + if (applicantIds.Count == 0) + { + return []; + } + + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var contactsQuery = await contactRepository.GetQueryableAsync(); + + return await ( + from link in contactLinksQuery + join contact in contactsQuery on link.ContactId equals contact.Id + where link.RelatedEntityType == ApplicantEntityType + && applicantIds.Contains(link.RelatedEntityId) + && link.IsActive + select new ContactInfoItemDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + ContactType = ApplicantEntityType, + Role = link.Role, + IsPrimary = link.IsPrimary, + IsEditable = isEditable, + ReferenceNo = null, + CreationTime = contact.CreationTime + }).ToListAsync(); + } + + private async Task> GetApplicationContactsByApplicantIdAsync(Guid applicantId) + { + var applicationContactsQuery = await applicationContactRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + // Resolve this applicant's applications up-front so we can map ReferenceNo by ApplicationId + // without relying on EF join translation (mirrors the ApplicantAddresses widget pattern). + var applicationRefMap = await applicationsQuery + .Where(a => a.ApplicantId == applicantId) + .Select(a => new { a.Id, a.ReferenceNo }) + .ToDictionaryAsync(a => a.Id, a => a.ReferenceNo); + + if (applicationRefMap.Count == 0) + { + return []; + } + + var applicationIds = applicationRefMap.Keys.ToList(); + + var contacts = await applicationContactsQuery + .Where(c => applicationIds.Contains(c.ApplicationId)) + .Select(appContact => new ContactInfoItemDto + { + ContactId = appContact.Id, + Name = appContact.ContactFullName, + Title = appContact.ContactTitle, + Email = appContact.ContactEmail, + MobilePhoneNumber = appContact.ContactMobilePhone, + WorkPhoneNumber = appContact.ContactWorkPhone, + Role = GetMatchingRole(appContact.ContactType), + ContactType = "Application", + IsPrimary = false, + IsEditable = false, + ApplicationId = appContact.ApplicationId, + CreationTime = appContact.CreationTime + }).ToListAsync(); + + foreach (var contact in contacts) + { + if (contact.ApplicationId.HasValue + && applicationRefMap.TryGetValue(contact.ApplicationId.Value, out var referenceNo)) + { + contact.ReferenceNo = referenceNo; + } + } + + return contacts; + } + + private async Task> GetApplicantAgentContactsByApplicantIdAsync(Guid applicantId) + { + var agentsQuery = await applicantAgentRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var agents = await agentsQuery + .Where(a => a.ApplicantId == applicantId) + .Select(agent => new ContactInfoItemDto + { + ContactId = agent.Id, + Name = agent.Name, + Title = agent.Title, + Email = agent.Email, + WorkPhoneNumber = agent.Phone, + WorkPhoneExtension = agent.PhoneExtension, + MobilePhoneNumber = agent.Phone2, + Role = agent.RoleForApplicant, + ContactType = "ApplicantAgent", + IsPrimary = false, + IsEditable = false, + ApplicationId = agent.ApplicationId, + CreationTime = agent.CreationTime + }).ToListAsync(); + + if (agents.Count == 0) + { + return agents; + } + + var applicationIds = agents + .Where(a => a.ApplicationId.HasValue) + .Select(a => a.ApplicationId!.Value) + .Distinct() + .ToList(); + + if (applicationIds.Count == 0) + { + return agents; + } + + var applicationRefMap = await applicationsQuery + .Where(a => applicationIds.Contains(a.Id)) + .Select(a => new { a.Id, a.ReferenceNo }) + .ToDictionaryAsync(a => a.Id, a => a.ReferenceNo); + + foreach (var agent in agents) + { + if (agent.ApplicationId.HasValue + && applicationRefMap.TryGetValue(agent.ApplicationId.Value, out var referenceNo)) + { + agent.ReferenceNo = referenceNo; + } + } + + return agents; + } + + /// + /// Ensures exactly one contact is flagged as primary. If none are explicitly flagged, + /// the most recently created contact is elected. + /// + private static void ResolvePrimary(List contacts) + { + if (contacts.Count == 0 || contacts.Any(c => c.IsPrimary)) + { + return; + } + + var latest = contacts + .OrderByDescending(c => c.CreationTime) + .First(); + latest.IsPrimary = true; + latest.IsPrimaryInferred = true; + } + + private static string GetMatchingRole(string contactType) + { + return ApplicationContactOptionList.ContactTypeList.TryGetValue(contactType, out string? value) + ? value : contactType; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs new file mode 100644 index 0000000000..99bb33d239 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applicants; +using Unity.Notifications.Settings; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; + +namespace Unity.GrantManager.ApplicantProfile +{ + /// + /// Internal query service that backs ApplicantProfileController. Aggregates applicant profile + /// data from registered implementations and resolves + /// the applicant's tenant mappings. Not exposed as an ABP application service: the HTTP surface + /// lives in the controller, which applies its own routing and API-key authorization. + /// + public class ApplicantProfileQueryService( + ICurrentTenant currentTenant, + IRepository applicantTenantMapRepository, + IEnumerable dataProviders, + ISettingProvider settingProvider, + ILogger logger) + : IApplicantProfileQueryService, ITransientDependency + { + private readonly Dictionary _providersByKey + = dataProviders.ToDictionary(p => p.Key, StringComparer.OrdinalIgnoreCase); + + /// + /// Retrieves the applicant's profile information based on the specified request. + /// + /// An object containing the criteria used to identify the applicant profile to retrieve. Must not be null. + /// A task that represents the asynchronous operation. The task result contains an with the applicant's profile data. + public async Task GetApplicantProfileAsync(ApplicantProfileInfoRequest request) + { + var dto = new ApplicantProfileDto + { + ProfileId = request.ProfileId, + Subject = request.Subject, + TenantId = request.TenantId, + Key = request.Key + }; + + if (_providersByKey.TryGetValue(request.Key, out var provider)) + { + dto.Data = await provider.GetDataAsync(request); + } + else + { + logger.LogWarning("Unknown applicant profile key provided"); + } + + return dto; + } + + /// + /// Retrieves a list of tenants associated with the specified applicant profile. + /// + /// The method extracts the username portion from the subject identifier in the request + /// to match tenant mappings. This operation is asynchronous and queries the host database for relevant tenant + /// associations. + /// An object containing applicant profile information, including the subject identifier used to locate tenant + /// mappings. + /// A list of objects representing the tenants linked to the applicant. The + /// list will be empty if no tenant associations are found. + public async Task> GetApplicantTenantsAsync(ApplicantProfileRequest request) + { + // Extract the username part from the OIDC sub (part before '@') + var subUsername = SubjectNormalizer.Normalize(request.Subject); + if (subUsername is null) return []; + List mappings = []; + + // Query the ApplicantTenantMaps table in the host database + using (currentTenant.Change(null)) + { + var queryable = await applicantTenantMapRepository.GetQueryableAsync(); + mappings = await queryable + .Where(m => m.OidcSubUsername == subUsername) + .Select(m => new ApplicantTenantDto + { + TenantId = m.TenantId, + TenantName = m.TenantName + }) + .ToListAsync(); + } + + // Apply tenant specific metadata + foreach (var map in mappings) + { + await AddTenantMetadataAsync(map); + } + + return mappings; + } + + /// + /// Add on any relevant tenant specific metadata + /// + /// The applicant tenant DTO to enrich with tenant-specific metadata. + private async Task AddTenantMetadataAsync(ApplicantTenantDto tenantMap) + { + using (currentTenant.Change(tenantMap.TenantId)) + { + var defaultEmailAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); + tenantMap.Metadata[ApplicantTenantMetadataKeys.DefaultFromAddress] = defaultEmailAddress ?? "NoReply@gov.bc.ca"; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs new file mode 100644 index 0000000000..8d43fe61ec --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applicants; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Internal reconciler for records. Not exposed on any +/// application-service contract — background workers depend on this directly. +/// +public class ApplicantTenantMapReconciler( + ICurrentTenant currentTenant, + ITenantRepository tenantRepository, + IRepository applicantTenantMapRepository, + IRepository applicationFormSubmissionRepository, + ILogger logger) + : IApplicantTenantMapReconciler, ITransientDependency +{ + /// + public async Task<(int Created, int Updated)> ReconcileAsync() + { + logger.LogInformation("Starting ApplicantTenantMap reconciliation..."); + + // Phase 1: Collect all OidcSub-to-tenant associations from each tenant DB + var desiredMappings = new List<(string SubUsername, Guid TenantId, string TenantName)>(); + var tenants = await tenantRepository.GetListAsync(); + + foreach (var tenant in tenants) + { + try + { + logger.LogDebug("Collecting submissions from tenant: {TenantName}", tenant.Name); + + using (currentTenant.Change(tenant.Id)) + { + var submissionQueryable = await applicationFormSubmissionRepository.GetQueryableAsync(); + var distinctOidcSubs = await submissionQueryable + .Where(s => !string.IsNullOrWhiteSpace(s.OidcSub) && s.OidcSub != Guid.Empty.ToString()) + .Select(s => s.OidcSub) + .Distinct() + .ToListAsync(); + + foreach (var oidcSub in distinctOidcSubs) + { + var subUsername = oidcSub.Contains('@') + ? oidcSub[..oidcSub.IndexOf('@')].ToUpperInvariant() + : oidcSub.ToUpperInvariant(); + + desiredMappings.Add((subUsername, tenant.Id, tenant.Name)); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error collecting submissions for tenant {TenantName}", tenant.Name); + } + } + + if (desiredMappings.Count == 0) + { + logger.LogInformation("ApplicantTenantMap reconciliation completed. No submissions found across tenants."); + return (0, 0); + } + + // Phase 2: Switch to host DB once, load existing mappings, and reconcile + int totalMappingsCreated = 0; + int totalMappingsUpdated = 0; + + using (currentTenant.Change(null)) + { + var allSubUsernames = desiredMappings.Select(m => m.SubUsername).Distinct().ToList(); + + var mapQueryable = await applicantTenantMapRepository.GetQueryableAsync(); + var existingMappings = await mapQueryable + .Where(m => allSubUsernames.Contains(m.OidcSubUsername)) + .ToListAsync(); + + var existingByKey = existingMappings + .ToDictionary(m => (m.OidcSubUsername, m.TenantId)); + + foreach (var (subUsername, tenantId, tenantName) in desiredMappings) + { + if (existingByKey.TryGetValue((subUsername, tenantId), out var existing)) + { + existing.LastUpdated = DateTime.UtcNow; + await applicantTenantMapRepository.UpdateAsync(existing); + totalMappingsUpdated++; + } + else + { + var newMapping = new ApplicantTenantMap + { + OidcSubUsername = subUsername, + TenantId = tenantId, + TenantName = tenantName, + LastUpdated = DateTime.UtcNow + }; + await applicantTenantMapRepository.InsertAsync(newMapping); + existingByKey[(subUsername, tenantId)] = newMapping; + totalMappingsCreated++; + logger.LogInformation("Created missing ApplicantTenantMap for {SubUsername} in tenant {TenantName}", + subUsername, tenantName); + } + } + } + + logger.LogInformation("ApplicantTenantMap reconciliation completed. Created: {Created}, Updated: {Updated}", + totalMappingsCreated, totalMappingsUpdated); + + return (totalMappingsCreated, totalMappingsUpdated); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs new file mode 100644 index 0000000000..4d8475bb1e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Internal helper that reconciles records by +/// scanning submissions across all tenants and syncing the host-database mapping table. +/// Intended for background-worker use only; not part of any public application-service contract. +/// +public interface IApplicantTenantMapReconciler +{ + /// + /// Phase 1: Collects all distinct OidcSub-to-tenant associations across all tenants. + /// Phase 2: Switches to the host DB once and reconciles all mappings. + /// + /// Tuple of (created count, updated count). + Task<(int Created, int Updated)> ReconcileAsync(); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs index 61d07b1ff4..d5f13cb6c5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs @@ -13,7 +13,7 @@ namespace Unity.GrantManager.Applicants.BackgroundWorkers [DisallowConcurrentExecution] public class ApplicantTenantMapReconciliationWorker : QuartzBackgroundWorkerBase { - private readonly IApplicantProfileAppService _applicantProfileAppService; + private readonly IApplicantTenantMapReconciler _reconciler; private readonly ILogger _logger; /// @@ -23,15 +23,15 @@ public class ApplicantTenantMapReconciliationWorker : QuartzBackgroundWorkerBase /// The scheduling behavior of the worker is determined by a cron expression retrieved /// from application settings. If the setting is unavailable or cannot be read, a default schedule is used. /// Logging is performed for any issues encountered during initialization. - /// The service used to access and manage applicant profile data. + /// The reconciler used to sync applicant-tenant maps across tenants. /// The setting manager used to retrieve configuration values, including the cron expression for scheduling. /// The logger used to record diagnostic and operational information for this worker. public ApplicantTenantMapReconciliationWorker( - IApplicantProfileAppService applicantProfileAppService, + IApplicantTenantMapReconciler reconciler, ISettingManager settingManager, ILogger logger) { - _applicantProfileAppService = applicantProfileAppService; + _reconciler = reconciler; _logger = logger; // 2 AM PST = 10 AM UTC @@ -93,7 +93,7 @@ public override async Task Execute(IJobExecutionContext context) try { - var (created, updated) = await _applicantProfileAppService.ReconcileApplicantTenantMapsAsync(); + var (created, updated) = await _reconciler.ReconcileAsync(); _logger.LogInformation("ApplicantTenantMapReconciliationWorker completed. Created: {Created}, Updated: {Updated}", created, updated); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs index f8fcc31f74..52b3d0d9da 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs @@ -20,7 +20,8 @@ namespace Unity.GrantManager.Contacts; [ExposeServices(typeof(ContactAppService), typeof(IContactAppService))] public class ContactAppService( IContactRepository contactRepository, - IContactLinkRepository contactLinkRepository) + IContactLinkRepository contactLinkRepository, + IContactManager contactManager) : GrantManagerAppService, IContactAppService { /// @@ -53,33 +54,60 @@ join contact in contactsQuery on link.ContactId equals contact.Id /// public async Task CreateContactAsync(CreateContactLinkDto input) { - var contact = await contactRepository.InsertAsync(new Contact - { - Name = input.Name, - Title = input.Title, - Email = input.Email, - HomePhoneNumber = input.HomePhoneNumber, - MobilePhoneNumber = input.MobilePhoneNumber, - WorkPhoneNumber = input.WorkPhoneNumber, - WorkPhoneExtension = input.WorkPhoneExtension - }, autoSave: true); + ArgumentNullException.ThrowIfNull(input); - if (input.IsPrimary) - { - await ClearPrimaryAsync(input.RelatedEntityType, input.RelatedEntityId); - } + var (contact, link) = await contactManager.CreateAsync( + input.RelatedEntityType, + input.RelatedEntityId, + ToContactInput(input), + input.Role, + input.IsPrimary); - await contactLinkRepository.InsertAsync(new ContactLink - { - ContactId = contact.Id, - RelatedEntityType = input.RelatedEntityType, - RelatedEntityId = input.RelatedEntityId, - Role = input.Role, - IsPrimary = input.IsPrimary, - IsActive = true - }, autoSave: true); + return MapToDto(contact, link); + } + + /// + public async Task UpdateContactAsync(string entityType, Guid entityId, Guid contactId, UpdateContactDto input) + { + ArgumentNullException.ThrowIfNull(input); + + var (contact, link) = await contactManager.UpdateAsync( + entityType, + entityId, + contactId, + ToContactInput(input), + input.Role, + input.IsPrimary); + + return MapToDto(contact, link); + } + + /// + public Task SetPrimaryContactAsync(string entityType, Guid entityId, Guid contactId) + { + return contactManager.SetPrimaryAsync(entityType, entityId, contactId); + } - return new ContactDto + private static ContactInput ToContactInput(CreateContactLinkDto input) => + new(input.Name, + input.Title, + input.Email, + input.HomePhoneNumber, + input.MobilePhoneNumber, + input.WorkPhoneNumber, + input.WorkPhoneExtension); + + private static ContactInput ToContactInput(UpdateContactDto input) => + new(input.Name, + input.Title, + input.Email, + input.HomePhoneNumber, + input.MobilePhoneNumber, + input.WorkPhoneNumber, + input.WorkPhoneExtension); + + private static ContactDto MapToDto(Contact contact, ContactLink link) => + new() { ContactId = contact.Id, Name = contact.Name, @@ -89,47 +117,7 @@ await contactLinkRepository.InsertAsync(new ContactLink MobilePhoneNumber = contact.MobilePhoneNumber, WorkPhoneNumber = contact.WorkPhoneNumber, WorkPhoneExtension = contact.WorkPhoneExtension, - Role = input.Role, - IsPrimary = input.IsPrimary + Role = link.Role, + IsPrimary = link.IsPrimary }; - } - - /// - public async Task SetPrimaryContactAsync(string entityType, Guid entityId, Guid contactId) - { - await ClearPrimaryAsync(entityType, entityId); - - var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); - var link = await contactLinksQuery - .Where(l => l.RelatedEntityType == entityType - && l.RelatedEntityId == entityId - && l.ContactId == contactId - && l.IsActive) - .FirstOrDefaultAsync() ?? throw new BusinessException("Contacts:ContactLinkNotFound") - .WithData("contactId", contactId) - .WithData("entityType", entityType) - .WithData("entityId", entityId); - link.IsPrimary = true; - await contactLinkRepository.UpdateAsync(link, autoSave: true); - } - - /// - /// Clears the primary flag on all active contact links for the specified entity. - /// - private async Task ClearPrimaryAsync(string entityType, Guid entityId) - { - var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); - var currentPrimaryLinks = await contactLinksQuery - .Where(l => l.RelatedEntityType == entityType - && l.RelatedEntityId == entityId - && l.IsPrimary - && l.IsActive) - .ToListAsync(); - - foreach (var existing in currentPrimaryLinks) - { - existing.IsPrimary = false; - await contactLinkRepository.UpdateAsync(existing, autoSave: true); - } - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 89d5df054e..9ccf13c8bb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -102,20 +102,20 @@ "ReviewerList:Subtotal": "Subtotal", "ReviewerList:CloneAssessment": "Clone Assessment", - "AssessmentResultAttachments:Id": "#", - "AssessmentResultAttachments:DocumentName": "Document Name", - "AssessmentResultAttachments:UploadedDate": "Date", - "AssessmentResultAttachments:AttachedBy": "Attached by", - "ChefsAttachments:Title": "Submission Attachments", - "ChefsAttachments:Filter": "Filter", - "ChefsAttachments:Download": "Download", - "ChefsAttachments:GenerateSummaries": "Generate AI Summaries", - "ChefsAttachments:GenerateSummary": "Generate Summary", - "ChefsAttachments:HideAISummaries": "Hide AI Summaries", - "ChefsAttachments:ShowAISummaries": "Show AI Summaries", - "ChefsAttachments:HideSummaries": "Hide Summaries", - "ChefsAttachments:ShowSummaries": "Show Summaries", - "ChefsAttachments:NoSummariesAvailable": "No summaries available", + "AssessmentResultAttachments:Id": "#", + "AssessmentResultAttachments:DocumentName": "Document Name", + "AssessmentResultAttachments:UploadedDate": "Date", + "AssessmentResultAttachments:AttachedBy": "Attached by", + "ChefsAttachments:Title": "Submission Attachments", + "ChefsAttachments:Filter": "Filter", + "ChefsAttachments:Download": "Download", + "ChefsAttachments:GenerateSummaries": "Generate AI Summaries", + "ChefsAttachments:GenerateSummary": "Generate Summary", + "ChefsAttachments:HideAISummaries": "Hide AI Summaries", + "ChefsAttachments:ShowAISummaries": "Show AI Summaries", + "ChefsAttachments:HideSummaries": "Hide Summaries", + "ChefsAttachments:ShowSummaries": "Show Summaries", + "ChefsAttachments:NoSummariesAvailable": "No summaries available", "Enum:AssessmentState.IN_PROGRESS": "In Progress", "Enum:AssessmentState.IN_REVIEW": "Under Review by Team Lead", @@ -473,34 +473,24 @@ "ApplicationContact:MobilePhone": "Mobile Phone Number", "ApplicationContact:WorkPhone": "Work Phone Number", "ApplicationContact:Delete": "Delete This Contact", - - "ApplicationBatchApprovalRequest:Title": "Approve Applications", - "ApplicationBatchApprovalRequest:SubmitButtonText": "Approve", - "ApplicationBatchApprovalRequest:CancelButtonText": "Cancel", - "ApplicationBatchApprovalRequest:DecisionDateDefaulted": "Decision Date has been defaulted", - "ApplicationBatchApprovalRequest:ApprovedAmountDefaulted": "Approved Amount has been defaulted", - "ApplicationBatchApprovalRequest:InvalidStatus": "The assessment for the selected item is not in the Assessment Completed state", - "ApplicationBatchApprovalRequest:InvalidPermissions": "Invalid permissions", - "ApplicationBatchApprovalRequest:InvalidApprovedAmount": "Invalid Approved Amount, it must be greater than 0.00", - "ApplicationBatchApprovalRequest:MaxCountExceeded": "You have exceeded the maximum number of items for bulk approval. Please reduce the number to {0} or fewer", - "ApplicationBatchApprovalRequest:InvalidRecommendedAmount": "Invalid Recommended Amount, it must be greater than 0.00", - - "ApplicationLinks:Category": "Category", - "ApplicationLinks:ID": "ID", - "ApplicationLinks:Status": "Status", - "ApplicationLinks:LinkType": "Link Type", - - "ApplicantPortalSettings:Title": "Applicant Portal Configuration", - "ApplicantPortalSettings:ManageStatuses": "Manage Statuses", - "ApplicantPortalSettings:PortalStatusHeading": "Applicant Portal Status", - "ApplicantPortalSettings:InternalStatus": "Internal Status", - "ApplicantPortalSettings:PortalStatusLabel": "Portal Status Label", - "ApplicantPortalSettings:SaveChanges": "Save Changes", - "ApplicantPortalSettings:ResetChanges": "Reset", - "ApplicantPortalSettings:SaveSuccess": "Portal status labels updated successfully.", - "ApplicantPortalSettings:SaveError": "An error occurred while saving portal status labels.", - "ApplicantPortalSettings:ValidationRequired": "Portal status label cannot be empty.", - "ApplicantPortalSettings:NoChanges": "No changes to save.", - "ApplicantPortalSettings:ChangesReset": "Changes have been reset to original values." + "ApplicantContact:SetAsPrimary": "Set as Primary", + "ApplicantContact:PrimaryHint": "This contact is currently primary because it was auto-selected by most recent timestamp. Saving with Set as Primary checked will explicitly set it as primary.", + "ApplicantContacts:ApplicantNotFound": "Applicant information not found.", + "ApplicantContacts:PrimaryContact": "Primary Contact", + "ApplicantContacts:NoPrimaryContact": "No primary contact on record.", + "ApplicantContacts:ContactsTitle": "Contacts", + "ApplicantContacts:PrimaryExplicitTooltip": "Primary contact", + "ApplicantContacts:PrimaryInferredTooltip": "Primary contact (auto-selected by most recent timestamp; not explicitly set).", + "ApplicantContacts:SourceInfoApplication": "Sourced from the Application submission. Managed on the Application Details form and cannot be edited here.", + "ApplicantContacts:SourceInfoApplicantAgent": "Sourced from the Applicant Agent on the CHEFS submission. Captured at intake and cannot be edited here.", + "ApplicantContacts:SourceInfoGeneric": "Sourced from {0} and cannot be edited here.", + "ApplicantContacts:View": "View", + "ApplicantContacts:ContactSaved": "Contact saved.", + "ApplicantContacts:ContactSetPrimary": "Contact set as primary.", + "ApplicantContacts:ServiceUnavailable": "Applicant contact service is not available.", + "ApplicantContacts:SetPrimaryFailed": "Failed to set contact as primary.", + "ApplicantContacts:ColumnName": "Name", + "ApplicantContacts:ColumnPhone": "Phone (Work)", + "ApplicantContacts:ColumnSubmission": "Submission #" } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactInput.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactInput.cs new file mode 100644 index 0000000000..df4bf78334 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactInput.cs @@ -0,0 +1,14 @@ +namespace Unity.GrantManager.Contacts; + +/// +/// Domain-layer input for creating or updating a . +/// Kept free of application-layer DTO dependencies so Domain remains self-contained. +/// +public record ContactInput( + string Name, + string? Title, + string? Email, + string? HomePhoneNumber, + string? MobilePhoneNumber, + string? WorkPhoneNumber, + string? WorkPhoneExtension); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs new file mode 100644 index 0000000000..5ab378ac8b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.Domain.Services; + +namespace Unity.GrantManager.Contacts; + +/// +/// Domain service that owns / write invariants +/// (create, update, set-primary). Application services delegate here so they do not need to +/// inject each other. +/// +public class ContactManager( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository) : DomainService, IContactManager +{ + /// + public virtual async Task<(Contact Contact, ContactLink Link)> CreateAsync( + string entityType, + Guid entityId, + ContactInput input, + string? role, + bool isPrimary) + { + ArgumentException.ThrowIfNullOrWhiteSpace(entityType); + ArgumentNullException.ThrowIfNull(input); + + var contact = await contactRepository.InsertAsync(new Contact + { + Name = input.Name, + Title = input.Title, + Email = input.Email, + HomePhoneNumber = input.HomePhoneNumber, + MobilePhoneNumber = input.MobilePhoneNumber, + WorkPhoneNumber = input.WorkPhoneNumber, + WorkPhoneExtension = input.WorkPhoneExtension + }, autoSave: true); + + if (isPrimary) + { + await ClearPrimaryAsync(entityType, entityId); + } + + var link = await contactLinkRepository.InsertAsync(new ContactLink + { + ContactId = contact.Id, + RelatedEntityType = entityType, + RelatedEntityId = entityId, + Role = role, + IsPrimary = isPrimary, + IsActive = true + }, autoSave: true); + + return (contact, link); + } + + /// + public virtual async Task<(Contact Contact, ContactLink Link)> UpdateAsync( + string entityType, + Guid entityId, + Guid contactId, + ContactInput input, + string? role, + bool isPrimary) + { + ArgumentException.ThrowIfNullOrWhiteSpace(entityType); + ArgumentNullException.ThrowIfNull(input); + + var contact = await contactRepository.GetAsync(contactId); + + contact.Name = input.Name; + contact.Title = input.Title; + contact.Email = input.Email; + contact.HomePhoneNumber = input.HomePhoneNumber; + contact.MobilePhoneNumber = input.MobilePhoneNumber; + contact.WorkPhoneNumber = input.WorkPhoneNumber; + contact.WorkPhoneExtension = input.WorkPhoneExtension; + + await contactRepository.UpdateAsync(contact, autoSave: true); + + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var links = await AsyncExecuter.ToListAsync(contactLinksQuery + .Where(l => l.RelatedEntityType == entityType + && l.RelatedEntityId == entityId + && l.IsActive)); + + var targetLink = links.FirstOrDefault(l => l.ContactId == contactId) + ?? throw new BusinessException("Contacts:ContactLinkNotFound") + .WithData("contactId", contactId) + .WithData("entityType", entityType) + .WithData("entityId", entityId); + + if (isPrimary) + { + foreach (var stale in links.Where(l => l.IsPrimary && l.ContactId != contactId)) + { + stale.IsPrimary = false; + await contactLinkRepository.UpdateAsync(stale, autoSave: true); + } + } + + var linkChanged = false; + if (targetLink.IsPrimary != isPrimary) + { + targetLink.IsPrimary = isPrimary; + linkChanged = true; + } + if (role is not null && targetLink.Role != role) + { + targetLink.Role = role; + linkChanged = true; + } + if (linkChanged) + { + await contactLinkRepository.UpdateAsync(targetLink, autoSave: true); + } + + return (contact, targetLink); + } + + /// + public virtual async Task SetPrimaryAsync(string entityType, Guid entityId, Guid contactId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(entityType); + + await ClearPrimaryAsync(entityType, entityId); + + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var link = await AsyncExecuter.FirstOrDefaultAsync(contactLinksQuery + .Where(l => l.RelatedEntityType == entityType + && l.RelatedEntityId == entityId + && l.ContactId == contactId + && l.IsActive)) + ?? throw new BusinessException("Contacts:ContactLinkNotFound") + .WithData("contactId", contactId) + .WithData("entityType", entityType) + .WithData("entityId", entityId); + + link.IsPrimary = true; + await contactLinkRepository.UpdateAsync(link, autoSave: true); + } + + /// + /// Clears the primary flag on all active contact links for the specified entity. + /// + private async Task ClearPrimaryAsync(string entityType, Guid entityId) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var currentPrimaryLinks = await AsyncExecuter.ToListAsync(contactLinksQuery + .Where(l => l.RelatedEntityType == entityType + && l.RelatedEntityId == entityId + && l.IsPrimary + && l.IsActive)); + + foreach (var existing in currentPrimaryLinks) + { + existing.IsPrimary = false; + await contactLinkRepository.UpdateAsync(existing, autoSave: true); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/IContactManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/IContactManager.cs new file mode 100644 index 0000000000..cf68d3360d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/IContactManager.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Contacts; + +/// +/// Domain service for managing and writes. +/// Centralises primary-contact invariants so application services can stay thin and do not +/// need to call each other across module boundaries. +/// +public interface IContactManager +{ + /// + /// Creates a new contact and links it to the specified related entity. + /// When is true, any existing primary link on the entity is cleared first. + /// + Task<(Contact Contact, ContactLink Link)> CreateAsync( + string entityType, + Guid entityId, + ContactInput input, + string? role, + bool isPrimary); + + /// + /// Updates an existing contact's fields and its link to the specified related entity. + /// When is true, any other primary link on the entity is cleared first. + /// is only applied when non-null, matching legacy behaviour. + /// + Task<(Contact Contact, ContactLink Link)> UpdateAsync( + string entityType, + Guid entityId, + Guid contactId, + ContactInput input, + string? role, + bool isPrimary); + + /// + /// Marks the specified contact as the primary contact for the given related entity, + /// clearing the primary flag on any other active links. + /// + Task SetPrimaryAsync(string entityType, Guid entityId, Guid contactId); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs index 127a2861ba..6fdf2c1c9f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -10,7 +10,7 @@ namespace Unity.GrantManager.Controllers [ApiController] [Route("api/app/applicant-profiles")] [ServiceFilter(typeof(ApiKeyAuthorizationFilter))] - public class ApplicantProfileController(IApplicantProfileAppService applicantProfileAppService) : AbpControllerBase + public class ApplicantProfileController(IApplicantProfileQueryService applicantProfileService) : AbpControllerBase { /// @@ -29,7 +29,7 @@ public class ApplicantProfileController(IApplicantProfileAppService applicantPro [ProducesResponseType(typeof(ApplicantProfileDto), StatusCodes.Status200OK)] public async Task GetApplicantProfileAsync([FromQuery] ApplicantProfileInfoRequest applicantProfileRequest) { - var profile = await applicantProfileAppService.GetApplicantProfileAsync(applicantProfileRequest); + var profile = await applicantProfileService.GetApplicantProfileAsync(applicantProfileRequest); return Ok(profile); } @@ -37,7 +37,7 @@ public async Task GetApplicantProfileAsync([FromQuery] ApplicantP [Route("tenants")] public async Task GetApplicantProfileTenantsAsync([FromQuery] ApplicantProfileRequest applicantProfileRequest) { - var tenants = await applicantProfileAppService.GetApplicantTenantsAsync(applicantProfileRequest); + var tenants = await applicantProfileService.GetApplicantTenantsAsync(applicantProfileRequest); return Ok(tenants); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebAutoMapperProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebAutoMapperProfile.cs new file mode 100644 index 0000000000..e38ed515e5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebAutoMapperProfile.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Web.Pages.ApplicantContact; + +namespace Unity.GrantManager.Web; + +public class GrantManagerWebAutoMapperProfile : Profile +{ + public GrantManagerWebAutoMapperProfile() + { + CreateMap() + .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.ContactId)); + + CreateMap(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/ApplicantContactModalViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/ApplicantContactModalViewModel.cs new file mode 100644 index 0000000000..4d9c66fada --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/ApplicantContactModalViewModel.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Unity.GrantManager.ApplicantProfile; + +namespace Unity.GrantManager.Web.Pages.ApplicantContact; + +public class ApplicantContactModalViewModel +{ + [HiddenInput] + public Guid ApplicantId { get; set; } + + [HiddenInput] + public Guid Id { get; set; } + + [HiddenInput] + public bool IsPrimaryInferred { get; set; } + + [DisplayName("ApplicationContact:Type")] + [Required] + [StringLength(100)] + public string Role { get; set; } = string.Empty; + + public List RoleOptions { get; set; } = CreateRoleOptions(); + + [DisplayName("ApplicationContact:FullName")] + [Required] + [MinLength(2)] + [StringLength(250)] + public string Name { get; set; } = string.Empty; + + [DisplayName("ApplicationContact:Title")] + [StringLength(200)] + public string? Title { get; set; } + + [DisplayName("ApplicationContact:Email")] + [EmailAddress] + [StringLength(200)] + public string? Email { get; set; } + + [DisplayName("ApplicationContact:MobilePhone")] + [StringLength(50)] + [RegularExpression(@"^[\+]?[0-9\-\.\(\)\s]*$", ErrorMessage = "Enter a valid phone number")] + public string? MobilePhoneNumber { get; set; } + + [DisplayName("ApplicationContact:WorkPhone")] + [StringLength(50)] + [RegularExpression(@"^[\+]?[0-9\-\.\(\)\s]*$", ErrorMessage = "Enter a valid phone number")] + public string? WorkPhoneNumber { get; set; } + + [DisplayName("ApplicantContact:SetAsPrimary")] + public bool IsPrimary { get; set; } + + public void EnsureRoleOptions() + { + if (RoleOptions is null || RoleOptions.Count == 0) + { + RoleOptions = CreateRoleOptions(); + } + } + + public static List CreateRoleOptions() + { + return ApplicantContactRoleOptions.Options + .Select(option => new SelectListItem { Value = option.Value, Text = option.Label }) + .ToList(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml new file mode 100644 index 0000000000..feb60c2bde --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml @@ -0,0 +1,45 @@ +@page +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal + +@model Unity.GrantManager.Web.Pages.ApplicantContact.EditModal + +@inject IStringLocalizer L +@{ + Layout = null; +} + + + + + +
+ + + + + + + + + + +
+
+ +
+
+ + @if (Model.ContactForm!.IsPrimary && Model.ContactForm.IsPrimaryInferred) + { +
@L["ApplicantContact:PrimaryHint"].Value
+ } +
+
+ + + + +
+ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml.cs new file mode 100644 index 0000000000..a9622bf3b9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantContact/EditModal.cshtml.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.GrantManager.Web.Pages.ApplicantContact; + +public class EditModal : AbpPageModel +{ + private readonly IApplicantContactAppService _applicantContactAppService; + + [BindProperty] + public ApplicantContactModalViewModel? ContactForm { get; set; } + + public EditModal(IApplicantContactAppService applicantContactAppService) + { + _applicantContactAppService = applicantContactAppService; + } + + public async Task OnGetAsync(Guid id, Guid applicantId) + { + var contactInfo = await _applicantContactAppService.GetByApplicantIdAsync(applicantId); + var contact = contactInfo.Contacts.FirstOrDefault(c => c.ContactId == id); + + if (contact is null || !contact.IsEditable) + { + return NotFound(); + } + + ContactForm = ObjectMapper.Map(contact); + ContactForm.ApplicantId = applicantId; + ContactForm.Id = contact.ContactId; + ContactForm.EnsureRoleOptions(); + + return Page(); + } + + public async Task OnPostAsync() + { + if (ContactForm is null) + { + return BadRequest(); + } + + ContactForm.EnsureRoleOptions(); + + if (!ModelState.IsValid) + { + return Page(); + } + + var updateDto = ObjectMapper.Map(ContactForm); + await _applicantContactAppService.UpdateAsync(ContactForm.ApplicantId, ContactForm.Id, updateDto); + + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs index da85ba2852..04ec4cd11d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs @@ -5,7 +5,6 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts { - [ApiController] [Route("Widget/ApplicantContacts")] public class ApplicantContactsController : AbpController { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs index 6300519eca..5661af72e9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewComponent.cs @@ -3,15 +3,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using Unity.GrantManager.Applications; -using Unity.GrantManager.Contacts; +using Unity.GrantManager.ApplicantProfile; using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; using Volo.Abp.Authorization.Permissions; -using Volo.Abp.Domain.Repositories; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts { @@ -20,30 +17,10 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts ScriptTypes = new[] { typeof(ApplicantContactsScriptBundleContributor) }, StyleTypes = new[] { typeof(ApplicantContactsStyleBundleContributor) }, AutoInitialize = true)] - public class ApplicantContactsViewComponent : AbpViewComponent + public class ApplicantContactsViewComponent( + IApplicantContactQueryService applicantContactQueryService, + IPermissionChecker permissionChecker) : AbpViewComponent { - private const string ApplicantEntityType = "Applicant"; - - private readonly IApplicantAgentRepository _applicantAgentRepository; - private readonly IPermissionChecker _permissionChecker; - private readonly IRepository _applicationRepository; - private readonly IContactRepository _contactRepository; - private readonly IContactLinkRepository _contactLinkRepository; - - public ApplicantContactsViewComponent( - IApplicantAgentRepository applicantAgentRepository, - IPermissionChecker permissionChecker, - IRepository applicationRepository, - IContactRepository contactRepository, - IContactLinkRepository contactLinkRepository) - { - _applicantAgentRepository = applicantAgentRepository; - _permissionChecker = permissionChecker; - _applicationRepository = applicationRepository; - _contactRepository = contactRepository; - _contactLinkRepository = contactLinkRepository; - } - public async Task InvokeAsync(Guid applicantId) { if (applicantId == Guid.Empty) @@ -51,129 +28,36 @@ public async Task InvokeAsync(Guid applicantId) return View(new ApplicantContactsViewModel { ApplicantId = applicantId }); } - var agents = await _applicantAgentRepository.GetListByApplicantIdAsync(applicantId); - var orderedAgents = agents - .OrderByDescending(a => a.LastModificationTime ?? a.CreationTime) - .ToList(); - - var appRefMap = await BuildApplicationReferenceMapAsync(orderedAgents); - var linkedContacts = await GetLinkedContactsAsync(applicantId); - var agentContacts = MapAgentContacts(orderedAgents, appRefMap); + var aggregated = await applicantContactQueryService.GetByApplicantIdAsync(applicantId); - var allContacts = agentContacts.Concat(linkedContacts) - .OrderByDescending(c => c.CreationTime) + var contacts = aggregated.Contacts + .OrderByDescending(c => c.IsPrimary) + .ThenByDescending(c => c.CreationTime) .ToList(); - ResolvePrimaryContact(allContacts); - var viewModel = new ApplicantContactsViewModel { ApplicantId = applicantId, - CanEditContact = await _permissionChecker.IsGrantedAsync(UnitySelector.Applicant.Contact.Update), - Contacts = allContacts + CanEditContact = await permissionChecker.IsGrantedAsync(UnitySelector.Applicant.Contact.Update), + Contacts = contacts }; - var primaryContact = allContacts.FirstOrDefault(c => c.IsPrimary); - if (primaryContact != null) + var primary = contacts.FirstOrDefault(c => c.IsPrimary); + if (primary != null) { - viewModel.PrimaryContact = new ApplicantPrimaryContactViewModel + viewModel.PrimaryContact = new ApplicantPrimaryContactDisplayModel { - Id = primaryContact.Id, - Source = primaryContact.Source, - FullName = primaryContact.Name, - Title = primaryContact.Title, - Email = primaryContact.Email, - BusinessPhone = primaryContact.Phone, - CellPhone = string.Empty + ContactId = primary.ContactId, + FullName = primary.Name ?? string.Empty, + Title = primary.Title ?? string.Empty, + Email = primary.Email ?? string.Empty, + WorkPhone = primary.WorkPhoneNumber ?? string.Empty, + MobilePhone = primary.MobilePhoneNumber ?? string.Empty }; } return View(viewModel); } - - private async Task> BuildApplicationReferenceMapAsync(List agents) - { - var appIds = new HashSet( - agents.Where(a => a.ApplicationId.HasValue).Select(a => a.ApplicationId!.Value)); - - var appRefMap = new Dictionary(); - if (appIds.Count > 0) - { - var apps = await _applicationRepository.GetListAsync(a => appIds.Contains(a.Id)); - foreach (var app in apps) - { - appRefMap[app.Id] = app.ReferenceNo; - } - } - - return appRefMap; - } - - private static List MapAgentContacts( - List agents, - Dictionary appRefMap) - { - return agents - .Select(agent => new ApplicantContactItemDto - { - Id = agent.Id, - Name = agent.Name ?? string.Empty, - Email = agent.Email ?? string.Empty, - Phone = !string.IsNullOrWhiteSpace(agent.Phone) - ? agent.Phone! - : agent.Phone2 ?? string.Empty, - Title = agent.Title ?? string.Empty, - Type = string.Empty, - Source = "Agent", - IsPrimary = false, - CreationTime = agent.CreationTime, - ApplicationId = agent.ApplicationId, - ReferenceNo = agent.ApplicationId.HasValue - ? appRefMap.GetValueOrDefault(agent.ApplicationId.Value, string.Empty) - : string.Empty - }) - .ToList(); - } - - private static void ResolvePrimaryContact(List contacts) - { - var primaryContact = contacts.FirstOrDefault(c => c.IsPrimary) - ?? contacts.FirstOrDefault(); - - if (primaryContact != null) - { - primaryContact.IsPrimary = true; - } - } - - private async Task> GetLinkedContactsAsync(Guid applicantId) - { - var contactLinksQuery = await _contactLinkRepository.GetQueryableAsync(); - var contactsQuery = await _contactRepository.GetQueryableAsync(); - - return await ( - from link in contactLinksQuery - join contact in contactsQuery on link.ContactId equals contact.Id - where link.RelatedEntityType == ApplicantEntityType - && link.RelatedEntityId == applicantId - && link.IsActive - select new ApplicantContactItemDto - { - Id = contact.Id, - Name = contact.Name, - Email = contact.Email ?? string.Empty, - Phone = !string.IsNullOrWhiteSpace(contact.WorkPhoneNumber) - ? contact.WorkPhoneNumber! - : contact.MobilePhoneNumber ?? string.Empty, - Title = contact.Title ?? string.Empty, - Type = link.Role ?? string.Empty, - Source = "Contact", - IsPrimary = link.IsPrimary, - CreationTime = contact.CreationTime, - ApplicationId = null, - ReferenceNo = string.Empty - }).ToListAsync(); - } } public class ApplicantContactsStyleBundleContributor : BundleContributor diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs index 74bb3b2916..6276717f85 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsViewModel.cs @@ -1,47 +1,34 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts { + /// + /// View model for the Applicant Contacts widget on the internal Applicant Details page. + /// Aggregates three contact sources via + /// : + /// applicant-linked (), application contacts, and applicant agent contacts. + /// Only applicant-linked rows are editable; the primary contact is shown as read-only fields. + /// public class ApplicantContactsViewModel { public Guid ApplicantId { get; set; } public bool CanEditContact { get; set; } - public bool CanSave => CanEditContact && PrimaryContact.IsEditable; - public ApplicantPrimaryContactViewModel PrimaryContact { get; set; } = new(); - public List Contacts { get; set; } = new(); + public List Contacts { get; set; } = []; + public ApplicantPrimaryContactDisplayModel? PrimaryContact { get; set; } + public IReadOnlyList RoleOptions { get; set; } = ApplicantContactRoleOptions.Options; } - public class ApplicantPrimaryContactViewModel + /// Read-only display model for the primary contact summary shown above the grid. + public class ApplicantPrimaryContactDisplayModel { - public Guid Id { get; set; } - public string Source { get; set; } = string.Empty; - [Display(Name = "Full Name")] + public Guid ContactId { get; set; } public string FullName { get; set; } = string.Empty; - [Display(Name = "Title")] public string Title { get; set; } = string.Empty; - [Display(Name = "Business Phone")] - public string BusinessPhone { get; set; } = string.Empty; - [Display(Name = "Cell Phone")] - public string CellPhone { get; set; } = string.Empty; - [Display(Name = "Email")] public string Email { get; set; } = string.Empty; - public bool IsEditable => Id != Guid.Empty; - } - - public class ApplicantContactItemDto - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public string Phone { get; set; } = string.Empty; - public string Title { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Source { get; set; } = string.Empty; - public bool IsPrimary { get; set; } - public DateTime CreationTime { get; set; } - public string ReferenceNo { get; set; } = string.Empty; - public Guid? ApplicationId { get; set; } + public string WorkPhone { get; set; } = string.Empty; + public string MobilePhone { get; set; } = string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml index f29c93bec3..c904e9d073 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.cshtml @@ -1,93 +1,112 @@ +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization @using Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts +@inject IStringLocalizer L @model ApplicantContactsViewModel @{ Layout = null; - var canEditPrimaryContact = Model.PrimaryContact.IsEditable && Model.CanEditContact; + var primary = Model.PrimaryContact; + const string placeholder = "—"; + var localizedTexts = new Dictionary + { + ["nullPlaceholder"] = placeholder, + ["primaryExplicitTooltip"] = L["ApplicantContacts:PrimaryExplicitTooltip"], + ["primaryInferredTooltip"] = L["ApplicantContacts:PrimaryInferredTooltip"], + ["sourceInfoApplication"] = L["ApplicantContacts:SourceInfoApplication"], + ["sourceInfoApplicantAgent"] = L["ApplicantContacts:SourceInfoApplicantAgent"], + ["sourceInfoGeneric"] = L["ApplicantContacts:SourceInfoGeneric"], + ["primaryContactVisuallyHidden"] = L["ApplicantContacts:PrimaryContact"], + ["edit"] = L["Common:Command:Edit"], + ["setAsPrimary"] = L["ApplicantContact:SetAsPrimary"], + ["view"] = L["ApplicantContacts:View"], + ["contactSaved"] = L["ApplicantContacts:ContactSaved"], + ["contactSetPrimary"] = L["ApplicantContacts:ContactSetPrimary"], + ["serviceUnavailable"] = L["ApplicantContacts:ServiceUnavailable"], + ["setPrimaryFailed"] = L["ApplicantContacts:SetPrimaryFailed"], + ["columnName"] = L["ApplicantContacts:ColumnName"], + ["columnType"] = L["ApplicationContact:Type"], + ["columnEmail"] = L["ApplicationContact:Email"], + ["columnPhone"] = L["ApplicantContacts:ColumnPhone"], + ["columnTitle"] = L["ApplicationContact:Title"], + ["columnSubmission"] = L["ApplicantContacts:ColumnSubmission"], + ["columnActions"] = string.Empty + }; } -
+
@if (Model.ApplicantId == Guid.Empty) {
- Applicant information not found. + @L["ApplicantContacts:ApplicantNotFound"]
} else { -
- - - - - @if (Model.CanSave) - { -
- -
- } - -
-
Primary Contact
-
-
+
+
+
+
@L["ApplicantContacts:PrimaryContact"]
+
+
+ @if (primary is null) + { +
@L["ApplicantContacts:NoPrimaryContact"]
+ } + else + { +
- + +
- + +
- + +
- + +
- + +
- @if (!Model.PrimaryContact.IsEditable) - { -
No primary contact on record.
- } -
-
+ } +
+
-
-
-
Contacts
-
-
- - -
+
+
+
@L["ApplicantContacts:ContactsTitle"]
+
+
+ +
- +
+
}
+ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.css index 844be9dbe5..3528115acc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.css @@ -4,6 +4,76 @@ position: relative; } +.applicant-contact-edit-form > .mb-3, +.applicant-contact-edit-form > .form-check, +.applicant-contact-edit-form > .abp-input, +.applicant-contact-edit-form > .abp-select { + width: 100%; +} + +.applicant-contacts-widget .applicant-contacts-info { + border-radius: 8px; + padding: 0 1rem 1rem 1rem; + margin-bottom: 0; +} + +.applicant-contacts-widget .applicant-contact-source-info { + display: inline-flex; + align-items: center; + cursor: help; + color: var(--bc-colors-grey-text-300); + font-size: 1rem; +} + +.applicant-contacts-widget .applicant-contact-source-info:hover { + color: var(--bc-colors-primary); +} + +.applicant-contacts-widget .applicant-contact-primary-badge { + display: inline-flex; + align-items: center; + margin-right: 0.35rem; + cursor: help; + font-size: 0.95rem; + opacity: 0.75; +} + +.applicant-contacts-widget .applicant-contact-primary-badge:hover { + opacity: 1; +} + +.applicant-contacts-widget .applicant-contact-edit-btn { + border: none; + box-shadow: none; + outline: none; + transition: none; +} + +.applicant-contacts-widget .applicant-contact-edit-btn:hover, +.applicant-contacts-widget .applicant-contact-edit-btn:focus { + border: none; + box-shadow: none; + outline: none; +} + +.applicant-contacts-widget .applicant-contact-menu-btn { + border: none; + box-shadow: none; + outline: none; + transition: none; + text-decoration: none; + color: var(--bc-colors-grey-text-300); +} + +.applicant-contacts-widget .applicant-contact-menu-btn:hover, +.applicant-contacts-widget .applicant-contact-menu-btn:focus { + border: none; + box-shadow: none; + outline: none; + text-decoration: none; + color: var(--bc-colors-primary); +} + .applicant-contacts-widget .applicant-organization-info { border-radius: 8px; padding: 0 1rem 1rem 1rem; @@ -37,6 +107,23 @@ border-bottom: 1px solid #dee2e6; } +.applicant-contacts-widget .primary-section__header { + padding: 0 0 0.5rem 0; +} + +.applicant-contacts-widget .primary-readonly dt { + font-size: var(--bc-font-size-sm); + color: var(--bc-colors-grey-text-300); + font-weight: 500; + margin-bottom: 0.125rem; +} + +.applicant-contacts-widget .primary-readonly dd { + font-size: var(--bc-font-size-base); + margin-bottom: 0; + word-break: break-word; +} + .applicant-contacts-widget .table-card { background-color: #fff; border: 1px solid #e0e6ed; @@ -58,6 +145,25 @@ width: 100%; } +.applicant-contacts-widget #ApplicantContactsTable td:last-child, +.applicant-contacts-widget #ApplicantContactsTable th:last-child { + text-align: center; + white-space: nowrap; +} + +.applicant-contacts-widget #ApplicantContactsTable td:last-child .applicant-contact-menu-btn, +.applicant-contacts-widget #ApplicantContactsTable td:last-child .applicant-contact-source-info, +.applicant-contacts-widget #ApplicantContactsTable td:last-child .applicant-contact-primary-badge { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-right: 0.25rem; +} + +.applicant-contacts-widget #ApplicantContactsTable td:last-child *:last-child { + margin-right: 0; +} + #ApplicantContactsTable_wrapper { width: 100%; overflow: visible !important; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js index c784527fee..813754da2d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js @@ -1,41 +1,259 @@ $(function () { const LAYOUT_NOTIFICATION_DELAYS = [0, 120, 300, 700]; - const contactsRaw = $('#ApplicantContacts_Data').val(); - const contactsData = safeParse(contactsRaw); - const nullPlaceholder = '—'; + let widgetRoot = $(); + let applicantId = null; + let canEdit = false; + let contactsData = []; + let roleLabelMap = {}; + let localizedTexts = {}; let contactsTable = null; - let zoneForm = null; + let savedOrder = null; + let editContactModal = null; - function renderTableLink(data, row) { - if (!data || !row.applicationId) { - return nullPlaceholder; + function t(key, fallback) { + return localizedTexts[key] || fallback; + } + + function format(template, value) { + return (template || '').replace('{0}', value); + } + + function ensureEditContactModal() { + if (editContactModal) { + return editContactModal; + } + + editContactModal = new abp.ModalManager(abp.appPath + 'ApplicantContact/EditModal'); + editContactModal.onResult(function () { + abp.notify.success(t('contactSaved', 'Contact saved.')); + refreshWidget(); + }); + + return editContactModal; + } + + function readWidgetState() { + widgetRoot = $('.applicant-contacts-widget'); + applicantId = widgetRoot.data('applicant-id'); + canEdit = widgetRoot.data('can-edit') === true || widgetRoot.data('can-edit') === 'true'; + + contactsData = safeParse($('#ApplicantContacts_Data').val()).map(toCamelCase); + localizedTexts = safeParse($('#ApplicantContacts_Texts').val()); + + roleLabelMap = {}; + safeParse($('#ApplicantContacts_RoleOptions').val()).forEach(function (option) { + const normalized = toCamelCase(option); + roleLabelMap[normalized.value] = normalized.label; + }); + } + + function pickCaseInsensitive(row, names) { // NOSONAR - intentionally scoped here; closure context is needed for widget encapsulation + if (!row) { return undefined; } + for (const name of names) { + if (row[name] !== undefined && row[name] !== null) { return row[name]; } + const lower = name.charAt(0).toLowerCase() + name.slice(1); + if (row[lower] !== undefined && row[lower] !== null) { return row[lower]; } + const upper = name.charAt(0).toUpperCase() + name.slice(1); + if (row[upper] !== undefined && row[upper] !== null) { return row[upper]; } + } + return undefined; + } + + function renderReferenceLink(data, type, row) { + const appId = pickCaseInsensitive(row, ['applicationId']); + const refNo = data || pickCaseInsensitive(row, ['referenceNo']); + const hasAppId = !!appId && appId !== '00000000-0000-0000-0000-000000000000'; + if (!hasAppId) { + return t('nullPlaceholder', '—'); } + const label = refNo || t('view', 'View'); + return `${label}`; + } - return `${data}`; + function renderPrimaryBadge(row) { // NOSONAR - intentionally scoped here; closure context is needed for widget encapsulation + if (!row.isPrimary) { + return ''; + } + const title = row.isPrimaryInferred + ? t('primaryInferredTooltip', 'Primary contact (auto-selected by most recent timestamp; not explicitly set).') + : t('primaryExplicitTooltip', 'Primary contact'); + return ` + + ${t('primaryContactVisuallyHidden', 'Primary contact')} + `; + } + + function renderActions(data, type, row) { + if (row.contactType !== 'Applicant') { + const message = row.contactType === 'Application' + ? t('sourceInfoApplication', 'Sourced from the Application submission. Managed on the Application Details form and cannot be edited here.') + : row.contactType === 'ApplicantAgent' + ? t('sourceInfoApplicantAgent', 'Sourced from the Applicant Agent on the CHEFS submission. Captured at intake and cannot be edited here.') + : format(t('sourceInfoGeneric', 'Sourced from {0} and cannot be edited here.'), row.contactType || 'another record'); + const escaped = $('
').text(message).html(); + return ` + + ${escaped} + `; + } + if (!canEdit) { + return ''; + } + const setPrimaryDisabled = row.isPrimary ? 'disabled' : ''; + return ``; + } + + function setAsPrimary(contact) { + const service = unity?.grantManager?.applicantProfile?.applicantContact; + if (!service) { + abp.notify.error(t('serviceUnavailable', 'Applicant contact service is not available.')); + return; + } + service.setPrimary(applicantId, contact.contactId) + .done(function () { + abp.notify.success(t('contactSetPrimary', 'Contact set as primary.')); + refreshWidget(); + }) + .fail(function () { + abp.notify.error(t('setPrimaryFailed', 'Failed to set contact as primary.')); + }); } - function initializeContactsTable(selector, data, columnDefs, extraConfig = {}) { - if (!$.fn.DataTable || !$(selector).length) { + function initializeContactsTable(order) { + if (!$.fn.DataTable || !$('#ApplicantContactsTable').length) { return null; } - return $(selector).DataTable( + if ($.fn.DataTable.isDataTable('#ApplicantContactsTable')) { + $('#ApplicantContactsTable').DataTable().destroy(); + } + + return $('#ApplicantContactsTable').DataTable( abp.libs.datatables.normalizeConfiguration({ - data: data, + data: contactsData, serverSide: false, - order: [[0, 'asc']], + order: order || [[0, 'asc']], searching: true, paging: true, pageLength: 10, select: false, info: true, + processing: true, scrollX: true, + stateSave: true, + stateDuration: 0, + stateSaveCallback: function (settings, data) { + try { + localStorage.setItem( + 'DataTables_ApplicantContactsTable_' + (applicantId || 'none'), + JSON.stringify(data)); + } catch (e) { console.error('Failed to save DataTables state to localStorage.', e); } + }, + stateLoadCallback: function () { + try { + const raw = localStorage.getItem( + 'DataTables_ApplicantContactsTable_' + (applicantId || 'none')); + return raw ? JSON.parse(raw) : null; + } catch (e) { console.error('Failed to load DataTables state from localStorage.', e); return null; } + }, drawCallback: function () { this.api().columns.adjust(); + $('#ApplicantContactsTable [data-bs-toggle="tooltip"]').each(function () { + const existing = bootstrap.Tooltip.getInstance(this); + if (existing) { existing.dispose(); } + bootstrap.Tooltip.getOrCreateInstance(this); + }); }, - ...extraConfig, - columnDefs: columnDefs + lengthMenu: [[10, 25, 50], [10, 25, 50]], + columnDefs: [ + { + title: t('columnName', 'Name'), + data: 'name', + width: '18%', + render: (data, type, row) => { + const name = data || t('nullPlaceholder', '—'); + if (type !== 'display') { + return name; + } + return renderPrimaryBadge(row) + name; + }, + targets: 0 + }, + { + title: t('columnType', 'Type'), + data: 'role', + width: '13%', + render: (data) => roleLabelMap[data] || data || t('nullPlaceholder', '—'), + targets: 1 + }, + { + title: t('columnEmail', 'Email'), + data: 'email', + width: '22%', + render: (data) => data || t('nullPlaceholder', '—'), + targets: 2 + }, + { + title: t('columnPhone', 'Phone'), + data: null, + width: '13%', + render: (data, type, row) => { + const phone = row.workPhoneNumber || row.mobilePhoneNumber; + return phone || t('nullPlaceholder', '—'); + }, + targets: 3 + }, + { + title: t('columnTitle', 'Title'), + data: 'title', + width: '18%', + render: (data) => data || t('nullPlaceholder', '—'), + targets: 4 + }, + { + title: t('columnSubmission', 'Submission #'), + data: 'referenceNo', + width: '10%', + render: renderReferenceLink, + targets: 5 + }, + { + title: t('columnActions', ''), + data: null, + orderable: false, + searchable: false, + width: '48px', + className: 'text-center', + render: renderActions, + targets: 6 + } + ] }) ); } @@ -46,121 +264,56 @@ $(function () { }); } - contactsTable = initializeContactsTable( - '#ApplicantContactsTable', - contactsData, - [ - { - title: 'Name', - data: 'name', - width: '18%', - render: (data) => data || nullPlaceholder - }, - { - title: 'Email', - data: 'email', - width: '22%', - render: (data) => data || nullPlaceholder - }, - { - title: 'Phone', - data: 'phone', - width: '13%', - render: (data) => data || nullPlaceholder - }, - { - title: 'Title', - data: 'title', - width: '17%', - render: (data) => data || nullPlaceholder - }, - { - title: 'Type', - data: 'type', - width: '10%', - render: (data) => data || nullPlaceholder - }, - { - title: 'Primary', - data: 'isPrimary', - width: '8%', - render: (data) => data ? 'Yes' : '' - }, - { - title: 'Submission #', - data: 'referenceNo', - width: '15%', - render: (data, type, row) => renderTableLink(data, row) - } - ], - { - lengthMenu: [[10, 25, 50], [10, 25, 50]] + function refreshWidget() { + if (contactsTable) { + try { + savedOrder = contactsTable.order(); + contactsTable.processing(true); + } catch (e) { console.error('Failed to enable DataTables processing indicator.', e); } } - ); - - scheduleLayoutNotifications(); - - const form = $('#ApplicantContactsForm'); - const saveButton = $('#saveApplicantContactsBtn'); - - if (form.length && saveButton.length && typeof UnityZoneForm === 'function') { - zoneForm = new UnityZoneForm(form, { - saveButtonSelector: '#saveApplicantContactsBtn' - }); - - zoneForm.init(); - - saveButton.on('click', function (event) { - event.preventDefault(); - if (!zoneForm || zoneForm.modifiedFields.size === 0) { - return; - } - - const applicantId = $('#ApplicantContacts_ApplicantId').val(); - if (!applicantId) { - abp.notify.warn('Applicant identifier is missing.'); - return; + $.ajax({ + url: abp.appPath + 'Widget/ApplicantContacts/Refresh', + type: 'GET', + dataType: 'html', + data: { applicantId: applicantId }, + success: function (html) { + const container = widgetRoot.parent(); + container.html(html); + bindWidget(savedOrder); + savedOrder = null; + abp.event.trigger('applicant-contacts-refreshed'); + }, + error: function () { + if (contactsTable) { + try { contactsTable.processing(false); } catch (e) { console.error('Failed to disable DataTables processing indicator.', e); } + } } + }); + } - const modifiedFields = Array.from(zoneForm.modifiedFields ?? []); - const contactDirty = modifiedFields.some((field) => field.startsWith('PrimaryContact.')); + function bindWidget(order) { + readWidgetState(); + contactsTable = initializeContactsTable(order); + scheduleLayoutNotifications(); + } - if (!contactDirty) { - return; - } + $(document).on('click', '.applicant-contact-edit-btn', function () { + const id = $(this).data('contact-id'); + ensureEditContactModal().open({ + id: id, + applicantId: applicantId + }); + }); - const contactId = $('#ApplicantContacts_PrimaryContactId').val(); - if (isGuidEmpty(contactId)) { - return; - } + $(document).on('click', '.applicant-contact-set-primary-btn', function () { + const id = $(this).data('contact-id'); + const contact = contactsData.find((c) => c.contactId === id); + if (!contact) return; + setAsPrimary(contact); + }); - const contactSource = $('#ApplicantContacts_PrimaryContactSource').val(); - - const payload = { - primaryContact: { - id: contactId, - source: contactSource, - fullName: form.find('[name="PrimaryContact.FullName"]').val(), - title: form.find('[name="PrimaryContact.Title"]').val(), - email: form.find('[name="PrimaryContact.Email"]').val(), - businessPhone: form.find('[name="PrimaryContact.BusinessPhone"]').val(), - cellPhone: form.find('[name="PrimaryContact.CellPhone"]').val() - } - }; - - unity.grantManager.applicants.applicant - .updateApplicantContactAddresses(applicantId, payload) - .done(function () { - abp.notify.success('Contact updated.'); - zoneForm.resetTracking(); - updateContactTableAfterSave(payload.primaryContact, contactsTable); - }) - .fail(function () { - abp.notify.error('Failed to update contact.'); - }); - }); - } + bindWidget(); }); function safeParse(value) { @@ -172,29 +325,20 @@ function safeParse(value) { } } -function notifyApplicantContactsLayoutChange() { - globalThis.dispatchEvent(new CustomEvent('applicant-contacts-layout-changed')); -} - -function isGuidEmpty(value) { - return !value || value === '00000000-0000-0000-0000-000000000000'; -} - -function updateContactTableAfterSave(contactPayload, contactsDt) { - if (!contactsDt || !contactPayload) { - return; +function toCamelCase(obj) { + if (obj === null || typeof obj !== 'object') { + return obj; } - - contactsDt.rows().every(function () { - const rowData = this.data(); - if (rowData.id === contactPayload.id) { - rowData.name = contactPayload.fullName || ''; - rowData.email = contactPayload.email || ''; - rowData.phone = contactPayload.businessPhone || contactPayload.cellPhone || ''; - rowData.title = contactPayload.title || ''; - this.data(rowData); - } + const result = {}; + Object.keys(obj).forEach(function (key) { + const camelKey = key.length > 0 + ? key.charAt(0).toLowerCase() + key.slice(1) + : key; + result[camelKey] = obj[key]; }); + return result; +} - contactsDt.rows().invalidate().draw(false); +function notifyApplicantContactsLayoutChange() { + globalThis.dispatchEvent(new CustomEvent('applicant-contacts-layout-changed')); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 010123c805..5316101917 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -24,27 +24,37 @@ public class ChefsAttachments( IApplicationFormRepository applicationFormRepository) : AbpViewComponent { public async Task InvokeAsync(Guid applicationFormId) - { - var featureEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); + { + // Set safe defaults so the view is never left with null ViewBag values + // even if an exception is thrown partway through the checks below. + ViewBag.IsAIAttachmentSummariesEnabled = false; + ViewBag.IsAIAttachmentSummariesGenerateEnabled = false; - // View guard — for toggling visibility of existing summaries - ViewBag.IsAIAttachmentSummariesEnabled = - featureEnabled && - await permissionChecker.IsGrantedAsync(AIPermissions.Analysis.ViewAttachmentSummary); + var featureEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); - // Generate guard — full 3-level chain for the Generate Summary button - var settingProvider = LazyServiceProvider.LazyGetRequiredService(); - var tenantManualEnabled = await settingProvider.GetAsync(AISettings.ManualGenerationEnabled, defaultValue: false); - var applicationForm = await applicationFormRepository.GetAsync(applicationFormId); + // View guard — for toggling visibility of existing summaries + ViewBag.IsAIAttachmentSummariesEnabled = + featureEnabled && + await permissionChecker.IsGrantedAsync(AIPermissions.Analysis.ViewAttachmentSummary); - ViewBag.IsAIAttachmentSummariesGenerateEnabled = - featureEnabled && - tenantManualEnabled && - applicationForm.ManuallyInitiateAIAnalysis && - await permissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateAttachmentSummaries); + if (applicationFormId == Guid.Empty) + { + return View(); + } - return View(); - } + // Generate guard — full 3-level chain for the Generate Summary button + var settingProvider = LazyServiceProvider.LazyGetRequiredService(); + var tenantManualEnabled = await settingProvider.GetAsync(AISettings.ManualGenerationEnabled, defaultValue: false); + var applicationForm = await applicationFormRepository.GetAsync(applicationFormId); + + ViewBag.IsAIAttachmentSummariesGenerateEnabled = + featureEnabled && + tenantManualEnabled && + applicationForm.ManuallyInitiateAIAnalysis && + await permissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateAttachmentSummaries); + + return View(); + } } public class ChefsAttachmentsStyleBundleContributor : BundleContributor diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 3a2b2aa3a0..b2a1f3478c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -7,7 +7,7 @@
Submission Attachments
- @if (ViewBag.IsAIAttachmentSummariesGenerateEnabled) + @if (ViewBag.IsAIAttachmentSummariesGenerateEnabled == true) { } - @if (ViewBag.IsAIAttachmentSummariesEnabled) + @if (ViewBag.IsAIAttachmentSummariesEnabled == true) { -
-
+ @if (aiAttachmentSummariesEnabled && aiApplicationAnalysisEnabled && aiScoringEnabled) + { + + } + +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index d0d87af7c8..53022382d6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -175,6 +175,33 @@ $(function () { $(selector).text(value ? `(${value})` : ''); } + function setAiGenerationStatus(value) { + $('#aiGenerationStatus').text(value ? `(${value})` : ''); + } + + function formatAiGenerationStatus(status) { + if (status === null || status === undefined || status === '') { + return ''; + } + + if (typeof status === 'string') { + return status; + } + + switch (status) { + case 0: + return 'Queued'; + case 1: + return 'Running'; + case 2: + return 'Completed'; + case 3: + return 'Failed'; + default: + return String(status); + } + } + function getScoresheetSchemaJson() { return $('#ApplicationScoresheetSchemaJson').val() || $('#AssessmentScoresheetSchemaJson').val() || @@ -351,6 +378,56 @@ $(function () { }); } + let aiGenerationPollTimeoutId = null; + + function stopAIGenerationPolling() { + if (aiGenerationPollTimeoutId) { + clearTimeout(aiGenerationPollTimeoutId); + aiGenerationPollTimeoutId = null; + } + } + + function pollAIGenerationStatus(applicationId, promptVersion, restoreButton, originalHtml) { + const poll = function() { + unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(applicationId, 'pipeline', promptVersion) + .done(function(request) { + const statusText = formatAiGenerationStatus(request?.status); + setAiGenerationStatus(statusText); + + if (statusText === 'Failed') { + stopAIGenerationPolling(); + setAiGenerationStatus('Failed'); + loadDevAiOutputs(); + restoreButton.html(originalHtml).prop('disabled', false); + abp.message.error(request?.failureReason || 'AI generate all failed.'); + return; + } + + if (!request || request.isActive === false || statusText === 'Completed') { + stopAIGenerationPolling(); + setAiGenerationStatus(''); + setDevAiOutputTimestamp('#analysisAiOutputTimestamp', request?.completedAt || request?.startedAt || null); + setDevAiOutputTimestamp('#scoringAiOutputTimestamp', request?.completedAt || request?.startedAt || null); + loadDevAiOutputs(); + restoreButton.html(originalHtml).prop('disabled', false); + if (statusText === 'Completed') { + abp.notify.success('AI generate all completed.'); + } + return; + } + + aiGenerationPollTimeoutId = setTimeout(poll, 2000); + }) + .fail(function() { + aiGenerationPollTimeoutId = setTimeout(poll, 3000); + }); + }; + + stopAIGenerationPolling(); + aiGenerationPollTimeoutId = setTimeout(poll, 500); + } + globalThis.refreshDevAiOutputs = loadDevAiOutputs; globalThis.generateAllAIDevOutputs = function(triggerButton = null) { @@ -366,18 +443,22 @@ $(function () { $button .html('Queueing...') .prop('disabled', true); - - unity.grantManager.grantApplications.applicationContent - .generateContent(applicationId, promptVersion) - .done(function() { - abp.notify.success('AI generate all queued. Refresh later to see updated results.'); + setAiGenerationStatus('Queueing'); + + unity.grantManager.grantApplications.grantApplication + .queueAIGeneration(applicationId, promptVersion) + .done(function(request) { + const statusText = formatAiGenerationStatus(request?.status); + setAiGenerationStatus(statusText || 'Queued'); + pollAIGenerationStatus(applicationId, promptVersion, $button, existingHtml); + abp.notify.success('AI generate all queued.'); }) .fail(function() { + setAiGenerationStatus(''); abp.message.error('Failed to queue AI generate all. Please try again.'); - }) - .always(function() { $button.html(existingHtml).prop('disabled', false); - }); + }) + ; }; $('#generateAllAiDevToolsBtn').on('click', function() { diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs new file mode 100644 index 0000000000..3d0e17d3e5 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -0,0 +1,132 @@ +using Medallion.Threading; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantApplications.Automation; +using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.DistributedLocking; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.GrantApplications.Automation; + +public class AIGenerationQueueTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper) +{ + [Fact] + public void BuildPipelineRequestKey_Should_Normalize_Identity() + { + var key = ApplicationAIGenerationQueue.BuildPipelineRequestKey( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + Guid.Parse("22222222-2222-2222-2222-222222222222"), + "v1"); + + key.ShouldBe("11111111-1111-1111-1111-111111111111:22222222-2222-2222-2222-222222222222:none:pipeline:v1"); + } + + [Fact] + public async Task QueueApplicationPipelineAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() + { + var tenantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + var promptVersion = "v1"; + var requestKey = ApplicationAIGenerationQueue.BuildPipelineRequestKey(tenantId, applicationId, promptVersion); + var request = new AIGenerationRequest( + Guid.NewGuid(), + tenantId, + AIGenerationRequestKeyHelper.PipelineOperationType, + applicationId, + null, + promptVersion, + requestKey); + + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var lockProvider = new TestDistributedLockProvider(); + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, lockProvider); + + await queue.QueueApplicationPipelineAsync(applicationId, tenantId, promptVersion); + + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any()); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueApplicationPipelineAsync_Should_Enqueue_New_Request_When_None_Exists() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.Arg())); + + RunApplicationAIPipelineJobArgs? capturedArgs = null; + var backgroundJobManager = Substitute.For(); + backgroundJobManager.EnqueueAsync(Arg.Any()) + .Returns(callInfo => + { + capturedArgs = callInfo.Arg(); + return Task.CompletedTask; + }); + + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + + await queue.QueueApplicationPipelineAsync(applicationId, tenantId, "v1"); + + capturedArgs.ShouldNotBeNull(); + capturedArgs!.ApplicationId.ShouldBe(applicationId); + capturedArgs.TenantId.ShouldBe(tenantId); + capturedArgs.PromptVersion.ShouldBe("v1"); + capturedArgs.RequestKey.ShouldBe(ApplicationAIGenerationQueue.BuildPipelineRequestKey(tenantId, applicationId, "v1")); + await repository.Received(1).InsertAsync(Arg.Is(r => + r.ApplicationId == applicationId && + r.TenantId == tenantId && + r.OperationType == AIGenerationRequestKeyHelper.PipelineOperationType && + r.RequestKey == capturedArgs.RequestKey && + r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); + } + + private sealed class TestDistributedLockProvider : IDistributedLockProvider + { + public IDistributedLock CreateLock(string name) => new TestDistributedLock(name); + } + + private sealed class TestDistributedLock(string name) : IDistributedLock + { + public string Name => name; + + public IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + new TestDistributedSynchronizationHandle(); + + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + ValueTask.FromResult(new TestDistributedSynchronizationHandle()); + + public IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + new TestDistributedSynchronizationHandle(); + + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + ValueTask.FromResult(new TestDistributedSynchronizationHandle()); + } + + private sealed class TestDistributedSynchronizationHandle : IDistributedSynchronizationHandle + { + public void Dispose() + { + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs deleted file mode 100644 index 64884e5c65..0000000000 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Unity.GrantManager.Applications; -using Unity.GrantManager.Attachments; -using Unity.GrantManager.GrantApplications; -using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; -using Unity.GrantManager.GrantApplications.Automation.Events; -using Volo.Abp.EventBus.Local; -using Volo.Abp.Features; -using Volo.Abp.MultiTenancy; -using Xunit; -using Xunit.Abstractions; - -namespace Unity.GrantManager.GrantApplications.Automation; - -public class RunApplicationAIPipelineJobTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper) -{ - private static RunApplicationAIPipelineJob BuildJob( - IFeatureChecker featureChecker, - IApplicationScoringAppService? scoringService = null) - { - var attachmentService = Substitute.For(); - attachmentService.GenerateAttachmentSummariesForPipelineAsync(Arg.Any>(), Arg.Any()) - .Returns(Task.FromResult(new List())); - - var analysisService = Substitute.For(); - analysisService.GenerateApplicationAnalysisForPipelineAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new ApplicationAnalysisResultDto { Completed = true })); - - return new RunApplicationAIPipelineJob( - Substitute.For(), - attachmentService, - analysisService, - scoringService ?? Substitute.For(), - featureChecker, - Substitute.For(), - Substitute.For(), - NullLogger.Instance); - } - - [Fact] - public async Task ExecuteAsync_Should_Skip_Scoring_When_Feature_Disabled() - { - var featureChecker = Substitute.For(); - featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(false); - featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(false); - featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(false); - - var scoringService = Substitute.For(); - scoringService.GenerateApplicationScoringForPipelineAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new ApplicationScoringResultDto { Completed = true })); - - var job = BuildJob(featureChecker, scoringService); - - await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs { ApplicationId = Guid.NewGuid() }); - - await scoringService.DidNotReceive().GenerateApplicationScoringForPipelineAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_Should_Run_Scoring_When_Feature_Enabled() - { - var featureChecker = Substitute.For(); - featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); - featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(false); - featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(false); - - var scoringService = Substitute.For(); - scoringService.GenerateApplicationScoringForPipelineAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new ApplicationScoringResultDto { Completed = true })); - - var job = BuildJob(featureChecker, scoringService); - - await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs { ApplicationId = Guid.NewGuid() }); - - await scoringService.Received(1).GenerateApplicationScoringForPipelineAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_Should_Publish_Scoring_Event_When_Scoring_Completes() - { - var featureChecker = Substitute.For(); - featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); - featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(false); - featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(false); - - var scoringService = Substitute.For(); - scoringService.GenerateApplicationScoringForPipelineAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new ApplicationScoringResultDto { Completed = true })); - - var eventBus = Substitute.For(); - var job = new RunApplicationAIPipelineJob( - Substitute.For(), - Substitute.For(), - Substitute.For(), - scoringService, - featureChecker, - eventBus, - Substitute.For(), - NullLogger.Instance); - - await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs { ApplicationId = Guid.NewGuid() }); - - await eventBus.Received(1).PublishAsync(Arg.Is(e => e.ApplicationId != Guid.Empty)); - } -} From 9b3bb396c475b660d3405e499ed75cf710340aca Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 17 Apr 2026 11:11:32 -0700 Subject: [PATCH 09/47] AB#32451 add AI request polling to generation flows --- .../AIGenerationRequestKeyHelper.cs | 3 + .../Pages/GrantApplications/Details.js | 94 ++++++++++++++++--- .../Pages/GrantApplications/ai-analysis.js | 49 +++++++++- .../Automation/AIGenerationQueueTests.cs | 9 +- 4 files changed, 139 insertions(+), 16 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs index e38b31aeeb..8b97e0574d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs @@ -4,6 +4,9 @@ namespace Unity.GrantManager.GrantApplications; public static class AIGenerationRequestKeyHelper { + public const string AttachmentSummaryOperationType = "attachment-summary"; + public const string ApplicationAnalysisOperationType = "application-analysis"; + public const string ApplicationScoringOperationType = "application-scoring"; public const string PipelineOperationType = "pipeline"; public static string BuildRequestKey(Guid? tenantId, Guid applicationId, string operationType, string? promptVersion = null, Guid? attachmentId = null) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 53022382d6..216f16bdfe 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -379,6 +379,7 @@ $(function () { } let aiGenerationPollTimeoutId = null; + const aiGenerationPollIntervalMs = 15000; function stopAIGenerationPolling() { if (aiGenerationPollTimeoutId) { @@ -387,10 +388,10 @@ $(function () { } } - function pollAIGenerationStatus(applicationId, promptVersion, restoreButton, originalHtml) { + function pollAIGenerationStatus(applicationId, operationType, promptVersion, restoreButton, originalHtml) { const poll = function() { unity.grantManager.grantApplications.grantApplication - .getAIGenerationStatus(applicationId, 'pipeline', promptVersion) + .getAIGenerationStatus(applicationId, operationType, promptVersion) .done(function(request) { const statusText = formatAiGenerationStatus(request?.status); setAiGenerationStatus(statusText); @@ -417,10 +418,10 @@ $(function () { return; } - aiGenerationPollTimeoutId = setTimeout(poll, 2000); + aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); }) .fail(function() { - aiGenerationPollTimeoutId = setTimeout(poll, 3000); + aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); }); }; @@ -428,6 +429,77 @@ $(function () { aiGenerationPollTimeoutId = setTimeout(poll, 500); } + function queueAIGenerationOperation(queueAction, operationType, queuedMessage, failureMessage, restoreButton, originalHtml) { + queueAction() + .done(function(request) { + const statusText = formatAiGenerationStatus(request?.status); + setAiGenerationStatus(statusText || 'Queued'); + pollAIGenerationStatus( + $('#DetailsViewApplicationId').val(), + operationType, + globalThis.getSelectedPromptVersion?.() || null, + restoreButton, + originalHtml + ); + abp.notify.success(queuedMessage); + }) + .fail(function() { + setAiGenerationStatus(''); + abp.message.error(failureMessage); + restoreButton.html(originalHtml).prop('disabled', false); + }); + } + + globalThis.queueAttachmentSummary = function(triggerButton = null) { + const applicationId = $('#DetailsViewApplicationId').val(); + const $button = triggerButton ? $(triggerButton) : $('[onclick*="queueAttachmentSummary"]').first(); + const existingHtml = $button.html(); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; + + if (!applicationId || $button.prop('disabled')) { + return; + } + + $button + .html('Queueing...') + .prop('disabled', true); + setAiGenerationStatus('Queueing'); + + queueAIGenerationOperation( + () => unity.grantManager.attachments.attachmentSummary.generateAttachmentSummary(applicationId, promptVersion), + 'attachment-summary', + 'AI attachment summary queued.', + 'Failed to queue AI attachment summary. Please try again.', + $button, + existingHtml + ); + }; + + globalThis.queueApplicationScoring = function(triggerButton = null) { + const applicationId = $('#DetailsViewApplicationId').val(); + const $button = triggerButton ? $(triggerButton) : $('[onclick*="queueApplicationScoring"]').first(); + const existingHtml = $button.html(); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; + + if (!applicationId || $button.prop('disabled')) { + return; + } + + $button + .html('Queueing...') + .prop('disabled', true); + setAiGenerationStatus('Queueing'); + + queueAIGenerationOperation( + () => unity.grantManager.grantApplications.applicationScoring.generateApplicationScoring(applicationId, promptVersion), + 'application-scoring', + 'AI application scoring queued.', + 'Failed to queue AI application scoring. Please try again.', + $button, + existingHtml + ); + }; + globalThis.refreshDevAiOutputs = loadDevAiOutputs; globalThis.generateAllAIDevOutputs = function(triggerButton = null) { @@ -446,13 +518,13 @@ $(function () { setAiGenerationStatus('Queueing'); unity.grantManager.grantApplications.grantApplication - .queueAIGeneration(applicationId, promptVersion) - .done(function(request) { - const statusText = formatAiGenerationStatus(request?.status); - setAiGenerationStatus(statusText || 'Queued'); - pollAIGenerationStatus(applicationId, promptVersion, $button, existingHtml); - abp.notify.success('AI generate all queued.'); - }) + .queueAIGeneration(applicationId, promptVersion) + .done(function(request) { + const statusText = formatAiGenerationStatus(request?.status); + setAiGenerationStatus(statusText || 'Queued'); + pollAIGenerationStatus(applicationId, 'pipeline', promptVersion, $button, existingHtml); + abp.notify.success('AI generate all queued.'); + }) .fail(function() { setAiGenerationStatus(''); abp.message.error('Failed to queue AI generate all. Please try again.'); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 254595cbdd..2c8dfbcd8c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -416,6 +416,7 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { const $button = triggerButton ? $(triggerButton) : $('#regenerateApplicationAnalysis'); const existingHtml = $button.html(); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; + const aiAnalysisPollIntervalMs = 15000; if (!applicationId || $button.prop('disabled')) { return; @@ -425,16 +426,58 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { .html('Queueing...') .prop('disabled', true); + let aiAnalysisPollTimeoutId = null; + const stopAIAnalysisPolling = function() { + if (aiAnalysisPollTimeoutId) { + clearTimeout(aiAnalysisPollTimeoutId); + aiAnalysisPollTimeoutId = null; + } + }; + + const poll = function() { + unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(applicationId, 'application-analysis', promptVersion) + .done(function(request) { + const statusText = request?.status ?? 'Queued'; + updateAnalysisTabStatus(statusText); + + if (statusText === 'Failed') { + stopAIAnalysisPolling(); + loadAIAnalysis(); + $button.html(existingHtml).prop('disabled', false); + abp.message.error(request?.failureReason || 'AI analysis failed.'); + return; + } + + if (!request || request.isActive === false || statusText === 'Completed') { + stopAIAnalysisPolling(); + loadAIAnalysis(); + $button.html(existingHtml).prop('disabled', false); + if (statusText === 'Completed') { + abp.notify.success('AI analysis completed.'); + } + return; + } + + aiAnalysisPollTimeoutId = setTimeout(poll, aiAnalysisPollIntervalMs); + }) + .fail(function() { + aiAnalysisPollTimeoutId = setTimeout(poll, aiAnalysisPollIntervalMs); + }); + }; + unity.grantManager.grantApplications.applicationAnalysis .generateApplicationAnalysis(applicationId, promptVersion) .then(function() { + updateAnalysisTabStatus('Queued'); abp.notify.success('AI analysis queued. Refresh later to see updated results.'); + stopAIAnalysisPolling(); + aiAnalysisPollTimeoutId = setTimeout(poll, 500); }) .catch(function() { - abp.message.error('Failed to queue AI analysis. Please try again.'); - }) - .always(function() { + stopAIAnalysisPolling(); $button.html(existingHtml).prop('disabled', false); + abp.message.error('Failed to queue AI analysis. Please try again.'); }); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index 3d0e17d3e5..14f0e18a4e 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -71,11 +71,14 @@ public async Task QueueApplicationPipelineAsync_Should_Enqueue_New_Request_When_ RunApplicationAIPipelineJobArgs? capturedArgs = null; var backgroundJobManager = Substitute.For(); - backgroundJobManager.EnqueueAsync(Arg.Any()) + backgroundJobManager.EnqueueAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(callInfo => { capturedArgs = callInfo.Arg(); - return Task.CompletedTask; + return Task.FromResult(string.Empty); }); var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); @@ -119,6 +122,8 @@ public ValueTask AcquireAsync(TimeSpan? timeo private sealed class TestDistributedSynchronizationHandle : IDistributedSynchronizationHandle { + public CancellationToken HandleLostToken => CancellationToken.None; + public void Dispose() { } From c16d63e93fa20b40d3c6ed2e5f17a3b7b146202b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 17 Apr 2026 12:42:47 -0700 Subject: [PATCH 10/47] AB#32451 cover AI queue dedupe paths --- .../Automation/AIGenerationQueueTests.cs | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index 14f0e18a4e..a9635b5bdc 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -98,6 +98,205 @@ await repository.Received(1).InsertAsync(Arg.Is(r => r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); } + [Fact] + public async Task QueueApplicationAnalysisAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() + { + var tenantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + var promptVersion = "v1"; + var requestKey = AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, promptVersion); + var request = new AIGenerationRequest( + Guid.NewGuid(), + tenantId, + AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, + applicationId, + null, + promptVersion, + requestKey); + + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + + await queue.QueueApplicationAnalysisAsync(applicationId, tenantId, promptVersion); + + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any()); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueApplicationAnalysisAsync_Should_Enqueue_New_Request_When_None_Exists() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var promptVersion = "v1"; + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.Arg())); + + GenerateApplicationAnalysisBackgroundJobArgs? capturedArgs = null; + var backgroundJobManager = Substitute.For(); + backgroundJobManager.EnqueueAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + capturedArgs = callInfo.Arg(); + return Task.FromResult(string.Empty); + }); + + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + + await queue.QueueApplicationAnalysisAsync(applicationId, tenantId, promptVersion); + + capturedArgs.ShouldNotBeNull(); + capturedArgs!.ApplicationId.ShouldBe(applicationId); + capturedArgs.TenantId.ShouldBe(tenantId); + capturedArgs.PromptVersion.ShouldBe(promptVersion); + capturedArgs.RequestKey.ShouldBe(AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, promptVersion)); + await repository.Received(1).InsertAsync(Arg.Is(r => + r.ApplicationId == applicationId && + r.TenantId == tenantId && + r.OperationType == AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType && + r.RequestKey == capturedArgs.RequestKey && + r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueAttachmentSummariesAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() + { + var tenantId = Guid.NewGuid(); + var attachmentId = Guid.NewGuid(); + var promptVersion = "v1"; + var requestKey = AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, attachmentId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, promptVersion); + var request = new AIGenerationRequest( + Guid.NewGuid(), + tenantId, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, + null, + attachmentId, + promptVersion, + requestKey); + + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + + await queue.QueueAttachmentSummariesAsync(new[] { attachmentId }, tenantId, promptVersion); + + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any()); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueAttachmentSummariesAsync_Should_Enqueue_New_Request_When_None_Exists() + { + var attachmentId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var promptVersion = "v1"; + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.Arg())); + + GenerateAttachmentSummaryBackgroundJobArgs? capturedArgs = null; + var backgroundJobManager = Substitute.For(); + backgroundJobManager.EnqueueAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + capturedArgs = callInfo.Arg(); + return Task.FromResult(string.Empty); + }); + + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + + await queue.QueueAttachmentSummariesAsync(new[] { attachmentId }, tenantId, promptVersion); + + capturedArgs.ShouldNotBeNull(); + capturedArgs!.AttachmentIds.ShouldContain(attachmentId); + capturedArgs.TenantId.ShouldBe(tenantId); + capturedArgs.PromptVersion.ShouldBe(promptVersion); + capturedArgs.RequestKey.ShouldBe(AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, attachmentId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, promptVersion)); + await repository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueApplicationScoringAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() + { + var tenantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + var promptVersion = "v1"; + var requestKey = AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType, promptVersion); + var request = new AIGenerationRequest( + Guid.NewGuid(), + tenantId, + AIGenerationRequestKeyHelper.ApplicationScoringOperationType, + applicationId, + null, + promptVersion, + requestKey); + + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + + await queue.QueueApplicationScoringAsync(applicationId, tenantId, promptVersion); + + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any()); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueApplicationScoringAsync_Should_Enqueue_New_Request_When_None_Exists() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var promptVersion = "v1"; + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.Arg())); + + GenerateApplicationScoringBackgroundJobArgs? capturedArgs = null; + var backgroundJobManager = Substitute.For(); + backgroundJobManager.EnqueueAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + capturedArgs = callInfo.Arg(); + return Task.FromResult(string.Empty); + }); + + var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + + await queue.QueueApplicationScoringAsync(applicationId, tenantId, promptVersion); + + capturedArgs.ShouldNotBeNull(); + capturedArgs!.ApplicationId.ShouldBe(applicationId); + capturedArgs.TenantId.ShouldBe(tenantId); + capturedArgs.PromptVersion.ShouldBe(promptVersion); + capturedArgs.RequestKey.ShouldBe(AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType, promptVersion)); + await repository.Received(1).InsertAsync(Arg.Is(r => + r.ApplicationId == applicationId && + r.TenantId == tenantId && + r.OperationType == AIGenerationRequestKeyHelper.ApplicationScoringOperationType && + r.RequestKey == capturedArgs.RequestKey && + r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); + } + private sealed class TestDistributedLockProvider : IDistributedLockProvider { public IDistributedLock CreateLock(string name) => new TestDistributedLock(name); From a0535dd7f7e7757d8d2290dad55ea94cdbcf1da8 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 17 Apr 2026 12:59:34 -0700 Subject: [PATCH 11/47] AB#32451 fix ai queue status predicate --- .../Automation/ApplicationAIGenerationQueue.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 969c5ba969..9d5d24d473 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -97,11 +97,9 @@ private async Task EnsureRequestAndEnqueueAsync( using (await requestLock.AcquireAsync()) { var query = await generationRequestRepository.GetQueryableAsync(); - var existingRequests = query.Where(x => - x.RequestKey == requestKey - && (x.Status == AIGenerationRequestStatus.Queued || x.Status == AIGenerationRequestStatus.Running)); - - var existing = existingRequests + var existing = query + .Where(x => x.RequestKey == requestKey + && (x.Status == AIGenerationRequestStatus.Queued || x.Status == AIGenerationRequestStatus.Running)) .OrderByDescending(x => x.CreationTime) .ThenByDescending(x => x.Id) .FirstOrDefault(); From f112b2f3acfd58758a0bb4e93a805a431b2fac37 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 17 Apr 2026 14:11:17 -0700 Subject: [PATCH 12/47] AB#32451 move AI requests into AI schema --- .../ApplicationAnalysisAppService.cs | 12 +- .../GenerateApplicationAnalysisJob.cs | 86 ++++++++ .../GenerateApplicationScoringJob.cs | 92 +++++++++ .../RunApplicationAIPipelineJob.cs | 185 ++++++++++++++++++ .../GrantManagerDbContext.cs | 2 +- .../20260415121500_AddAIGenerationRequests.cs | 18 +- .../GrantManagerDbContextModelSnapshot.cs | 2 +- .../ApplicationAnalysisAppServiceTests.cs | 13 +- .../RunApplicationAIPipelineJobTests.cs | 175 +++++++++++++++++ 9 files changed, 566 insertions(+), 19 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs index c5d8814534..6dc533506a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs @@ -2,17 +2,19 @@ using System; using System.Threading.Tasks; using Unity.AI; -using Unity.AI.Operations; +using Unity.AI.Automation; using Unity.AI.Permissions; using Volo.Abp; using Volo.Abp.Features; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] public class ApplicationAnalysisAppService( - IApplicationAnalysisService applicationAnalysisService, - IFeatureChecker featureChecker) + IApplicationAIGenerationQueue aiGenerationQueue, + IFeatureChecker featureChecker, + ICurrentTenant currentTenant) : AIAppService, IApplicationAnalysisAppService { public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) @@ -22,8 +24,8 @@ public virtual async Task GenerateApplicationAnaly throw new UserFriendlyException("AI application analysis is not enabled."); } - await applicationAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion); - return new ApplicationAnalysisResultDto { Completed = true }; + await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, currentTenant.Id, promptVersion); + return new ApplicationAnalysisResultDto { Completed = false }; } // Internal-only: no HTTP endpoint, no auth check — safe for background job callers diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs new file mode 100644 index 0000000000..3ae554b632 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.AI.Operations; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateApplicationAnalysisJob( + IApplicationAnalysisService applicationAnalysisService, + IRepository generationRequestRepository, + ICurrentTenant currentTenant, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + await MarkRunningAsync(args.RequestKey); + try + { + logger.LogInformation("Executing AI application analysis job for application {ApplicationId}.", args.ApplicationId); + await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId); + + await MarkCompletedAsync(args.RequestKey); + } + catch (Exception ex) + { + await MarkFailedAsync(args.RequestKey, ex.Message); + throw; + } + } + } + + private async Task MarkRunningAsync(string requestKey) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkRunning(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task MarkCompletedAsync(string requestKey) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkCompleted(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task MarkFailedAsync(string requestKey, string? failureReason) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkFailed(DateTime.UtcNow, failureReason); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task GetRequestAsync(string requestKey) + { + var query = await generationRequestRepository.GetQueryableAsync(); + return query + .Where(x => x.RequestKey == requestKey) + .OrderByDescending(x => x.CreationTime) + .ThenByDescending(x => x.Id) + .FirstOrDefault(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs new file mode 100644 index 0000000000..4f4722a35b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.AI.Operations; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantApplications.Automation.Events; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.EventBus.Local; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateApplicationScoringJob( + IApplicationScoringService applicationScoringService, + ILocalEventBus localEventBus, + IRepository generationRequestRepository, + ICurrentTenant currentTenant, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + await MarkRunningAsync(args.RequestKey); + try + { + logger.LogInformation("Executing AI application scoring job for application {ApplicationId}.", args.ApplicationId); + await applicationScoringService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent + { + ApplicationId = args.ApplicationId + }); + + await MarkCompletedAsync(args.RequestKey); + } + catch (Exception ex) + { + await MarkFailedAsync(args.RequestKey, ex.Message); + throw; + } + } + } + + private async Task MarkRunningAsync(string requestKey) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkRunning(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task MarkCompletedAsync(string requestKey) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkCompleted(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task MarkFailedAsync(string requestKey, string? failureReason) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkFailed(DateTime.UtcNow, failureReason); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task GetRequestAsync(string requestKey) + { + var query = await generationRequestRepository.GetQueryableAsync(); + return query + .Where(x => x.RequestKey == requestKey) + .OrderByDescending(x => x.CreationTime) + .ThenByDescending(x => x.Id) + .FirstOrDefault(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs new file mode 100644 index 0000000000..df8a45ce39 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs @@ -0,0 +1,185 @@ +using Microsoft.Extensions.Logging; +using Medallion.Threading; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.AI; +using Unity.AI.Operations; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantApplications.Automation.Events; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Features; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class RunApplicationAIPipelineJob( + IAIService aiService, + IAttachmentSummaryService attachmentSummaryService, + IApplicationAnalysisService applicationAnalysisService, + IApplicationScoringService applicationScoringService, + IFeatureChecker featureChecker, + ILocalEventBus localEventBus, + ICurrentTenant currentTenant, + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IRepository generationRequestRepository, + IDistributedLockProvider distributedLockProvider, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + var requestKey = string.IsNullOrWhiteSpace(args.RequestKey) + ? ApplicationAIGenerationQueue.BuildPipelineRequestKey(args.TenantId, args.ApplicationId, args.PromptVersion) + : args.RequestKey; + var executionLock = distributedLockProvider.CreateLock($"ai-generation-run:{requestKey}"); + + using (await executionLock.AcquireAsync()) + { + try + { + var request = await GetRequestAsync(requestKey, args.ApplicationId); + + if (request != null && request.Status == AIGenerationRequestStatus.Completed) + { + logger.LogDebug("AI generation request {RequestKey} is already completed for application {ApplicationId}.", requestKey, args.ApplicationId); + return; + } + + if (request != null) + { + request.MarkRunning(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + var application = await applicationRepository.GetAsync(args.ApplicationId); + var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); + + if (!applicationForm.AutomaticallyGenerateAIAnalysis) + { + logger.LogDebug("Automatic AI analysis is disabled at form level for application {ApplicationId}, skipping intake pipeline.", args.ApplicationId); + await MarkCompletedAsync(requestKey, args.ApplicationId); + return; + } + + var attachmentSummariesEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); + var applicationAnalysisEnabled = await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"); + var scoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring"); + if (!attachmentSummariesEnabled && !applicationAnalysisEnabled && !scoringEnabled) + { + logger.LogDebug("All AI features are disabled, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); + await MarkCompletedAsync(requestKey, args.ApplicationId); + return; + } + + if (!await aiService.IsAvailableAsync()) + { + logger.LogWarning("AI service is not available, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); + await MarkFailedAsync(requestKey, args.ApplicationId, "AI service is not available."); + return; + } + + logger.LogInformation("Executing queued AI content pipeline for application {ApplicationId}.", args.ApplicationId); + if (attachmentSummariesEnabled) + { + await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); + } + + Exception? analysisException = null; + Exception? scoringException = null; + if (applicationAnalysisEnabled) + { + try + { + await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + } + catch (Exception ex) + { + analysisException = ex; + logger.LogError(ex, "Error executing AI application analysis stage for application {ApplicationId}.", args.ApplicationId); + } + } + + if (scoringEnabled) + { + try + { + var result = await applicationScoringService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + if (!string.Equals(result, "{}", StringComparison.Ordinal)) + { + await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent + { + ApplicationId = args.ApplicationId + }); + } + } + catch (Exception ex) + { + scoringException = ex; + logger.LogError(ex, "Error executing AI application scoring stage for application {ApplicationId}.", args.ApplicationId); + } + } + + if (scoringException != null) + { + await MarkFailedAsync(requestKey, args.ApplicationId, scoringException.Message); + throw scoringException; + } + + if (analysisException != null) + { + await MarkFailedAsync(requestKey, args.ApplicationId, analysisException.Message); + throw analysisException; + } + + await MarkCompletedAsync(requestKey, args.ApplicationId); + } + catch (Exception ex) + { + await MarkFailedAsync(requestKey, args.ApplicationId, ex.Message); + throw; + } + } + } + } + + private async Task GetRequestAsync(string requestKey, Guid applicationId) + { + var query = await generationRequestRepository.GetQueryableAsync(); + return query + .Where(x => x.RequestKey == requestKey && x.ApplicationId == applicationId) + .OrderByDescending(x => x.CreationTime) + .ThenByDescending(x => x.Id) + .FirstOrDefault(); + } + + private async Task MarkCompletedAsync(string requestKey, Guid applicationId) + { + var request = await GetRequestAsync(requestKey, applicationId); + if (request == null) + { + return; + } + + request.MarkCompleted(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task MarkFailedAsync(string requestKey, Guid applicationId, string? failureReason) + { + var request = await GetRequestAsync(requestKey, applicationId); + if (request == null) + { + return; + } + + request.MarkFailed(DateTime.UtcNow, failureReason); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 2cdb96eeba..a4df989e11 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -240,7 +240,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(b => { - b.ToTable(GrantManagerConsts.DbTablePrefix + "AIGenerationRequests", GrantManagerConsts.DbSchema); + b.ToTable(GrantManagerConsts.DbTablePrefix + "AIRequests", AIDbProperties.DbSchema); b.ConfigureByConvention(); b.Property(x => x.OperationType).IsRequired().HasMaxLength(64); b.Property(x => x.RequestKey).IsRequired().HasMaxLength(256); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.cs index f90a5d4523..1a118a5a47 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.cs @@ -10,7 +10,8 @@ public partial class AddAIGenerationRequests : Migration protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( - name: "AIGenerationRequests", + name: "AIRequests", + schema: "AI", columns: table => new { Id = table.Column(type: "uuid", nullable: false), @@ -36,23 +37,26 @@ protected override void Up(MigrationBuilder migrationBuilder) }, constraints: table => { - table.PrimaryKey("PK_AIGenerationRequests", x => x.Id); + table.PrimaryKey("PK_AIRequests", x => x.Id); }); migrationBuilder.CreateIndex( - name: "IX_AIGenerationRequests_ApplicationId_OperationType_Status", - table: "AIGenerationRequests", + name: "IX_AIRequests_ApplicationId_OperationType_Status", + schema: "AI", + table: "AIRequests", columns: new[] { "ApplicationId", "OperationType", "Status" }); migrationBuilder.CreateIndex( - name: "IX_AIGenerationRequests_RequestKey", - table: "AIGenerationRequests", + name: "IX_AIRequests_RequestKey", + schema: "AI", + table: "AIRequests", column: "RequestKey"); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( - name: "AIGenerationRequests"); + name: "AIRequests", + schema: "AI"); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index 495e324107..5dc0832bb0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -1362,7 +1362,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("RequestKey"); - b.ToTable("AIGenerationRequests", (string)null); + b.ToTable("AIRequests", "AI"); }); modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationAnalysisAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationAnalysisAppServiceTests.cs index 46c07f08ef..de59448fdf 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationAnalysisAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationAnalysisAppServiceTests.cs @@ -2,9 +2,10 @@ using Shouldly; using System; using System.Threading.Tasks; -using Unity.AI.Operations; +using Unity.AI.Automation; using Unity.GrantManager.GrantApplications; using Volo.Abp.Features; +using Volo.Abp.MultiTenancy; using Xunit; using Xunit.Abstractions; @@ -18,14 +19,16 @@ public async Task GenerateApplicationAnalysisAsync_Should_Return_Completed_Resul var featureChecker = Substitute.For(); featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(true); - var analysisService = Substitute.For(); - analysisService.RegenerateAndSaveAsync(Arg.Any(), Arg.Any()).Returns("analysis"); + var queue = Substitute.For(); + var currentTenant = Substitute.For(); + currentTenant.Id.Returns(Guid.NewGuid()); - var service = new ApplicationAnalysisAppService(analysisService, featureChecker); + var service = new ApplicationAnalysisAppService(queue, featureChecker, currentTenant); var result = await service.GenerateApplicationAnalysisAsync(Guid.NewGuid()); result.ShouldNotBeNull(); - result.Completed.ShouldBeTrue(); + result.Completed.ShouldBeFalse(); + await queue.Received(1).QueueApplicationAnalysisAsync(Arg.Any(), currentTenant.Id, Arg.Any()); } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs new file mode 100644 index 0000000000..ff8618254c --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs @@ -0,0 +1,175 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Medallion.Threading; +using NSubstitute; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Unity.AI; +using Unity.AI.Operations; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Attachments; +using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.DistributedLocking; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Features; +using Volo.Abp.MultiTenancy; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.GrantApplications.Automation; + +public class RunApplicationAIPipelineJobTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper) +{ + [Fact] + public async Task ExecuteAsync_Should_Mark_Request_Completed_When_Features_Disabled() + { + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync(Arg.Any()).Returns(false); + + var repository = BuildRequestRepository(out var requests); + var applicationId = Guid.NewGuid(); + requests.Add(CreateRequest(applicationId)); + + var job = BuildJob(featureChecker, repository); + + await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs + { + ApplicationId = applicationId, + RequestKey = requests[0].RequestKey + }); + + Assert.Equal(AIGenerationRequestStatus.Completed, requests[0].Status); + } + + [Fact] + public async Task ExecuteAsync_Should_Not_Run_When_Request_Already_Completed() + { + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync(Arg.Any()).Returns(true); + + var repository = BuildRequestRepository(out var requests); + var applicationId = Guid.NewGuid(); + var request = CreateRequest(applicationId); + request.MarkCompleted(DateTime.UtcNow); + requests.Add(request); + + var scoringService = Substitute.For(); + + var job = BuildJob(featureChecker, repository, scoringService: scoringService); + + await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs + { + ApplicationId = applicationId, + RequestKey = request.RequestKey + }); + + await scoringService.DidNotReceive().RegenerateAndSaveAsync(Arg.Any(), Arg.Any()); + } + + private static RunApplicationAIPipelineJob BuildJob( + IFeatureChecker featureChecker, + IRepository generationRequestRepository, + IAttachmentSummaryService? attachmentSummaryService = null, + IApplicationAnalysisService? applicationAnalysisService = null, + IApplicationScoringService? scoringService = null, + IDistributedLockProvider? distributedLockProvider = null) + { + var application = (Application)RuntimeHelpers.GetUninitializedObject(typeof(Application)); + application.ApplicationFormId = Guid.NewGuid(); + + var applicationForm = (ApplicationForm)RuntimeHelpers.GetUninitializedObject(typeof(ApplicationForm)); + applicationForm.AutomaticallyGenerateAIAnalysis = true; + + var applicationRepository = Substitute.For(); + applicationRepository.GetAsync(Arg.Any(), Arg.Any()).Returns(application); + + var applicationFormRepository = Substitute.For(); + applicationFormRepository.GetAsync(Arg.Any(), Arg.Any()).Returns(applicationForm); + + return new RunApplicationAIPipelineJob( + Substitute.For(), + attachmentSummaryService ?? Substitute.For(), + applicationAnalysisService ?? Substitute.For(), + scoringService ?? Substitute.For(), + featureChecker, + Substitute.For(), + Substitute.For(), + applicationRepository, + applicationFormRepository, + generationRequestRepository, + distributedLockProvider ?? new TestDistributedLockProvider(), + NullLogger.Instance); + } + + private static IRepository BuildRequestRepository(out List requests) + { + var requestList = new List(); + requests = requestList; + var repository = Substitute.For>(); + + repository.GetQueryableAsync().Returns(Task.FromResult>(requestList.AsQueryable())); + repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + requestList.Add(request); + return Task.FromResult(request); + }); + repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.Arg())); + + return repository; + } + + private static AIGenerationRequest CreateRequest(Guid applicationId) + { + return new AIGenerationRequest( + Guid.NewGuid(), + Guid.NewGuid(), + AIGenerationRequestKeyHelper.PipelineOperationType, + applicationId, + null, + null, + $"tenant:{Guid.NewGuid():D}:application:{applicationId:D}:none:pipeline:default"); + } + + private sealed class TestDistributedLockProvider : IDistributedLockProvider + { + public IDistributedLock CreateLock(string name) => new TestDistributedLock(name); + } + + private sealed class TestDistributedLock(string name) : IDistributedLock + { + public string Name => name; + + public IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default) => + new TestDistributedSynchronizationHandle(); + + public ValueTask AcquireAsync(TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default) => + ValueTask.FromResult(new TestDistributedSynchronizationHandle()); + + public IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, System.Threading.CancellationToken cancellationToken = default) => + new TestDistributedSynchronizationHandle(); + + public ValueTask TryAcquireAsync(TimeSpan timeout = default, System.Threading.CancellationToken cancellationToken = default) => + ValueTask.FromResult(new TestDistributedSynchronizationHandle()); + } + + private sealed class TestDistributedSynchronizationHandle : IDistributedSynchronizationHandle + { + public System.Threading.CancellationToken HandleLostToken => System.Threading.CancellationToken.None; + + public void Dispose() + { + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} From a30df8f3e6ce37088c05ec781a8ac172e3a7ee18 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 17 Apr 2026 15:32:05 -0700 Subject: [PATCH 13/47] AB#32451 scaffold AI request migration --- ...121500_AddAIGenerationRequests.Designer.cs | 619 ++++++++++++++++++ .../GrantManagerDbContextModelSnapshot.cs | 214 +++--- 2 files changed, 725 insertions(+), 108 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.Designer.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.Designer.cs new file mode 100644 index 0000000000..6cc12bf009 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIGenerationRequests.Designer.cs @@ -0,0 +1,619 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + [DbContext(typeof(GrantManagerDbContext))] + [Migration("20260415121500_AddAIGenerationRequests")] + partial class AddAIGenerationRequests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", (string)null); + }); + + modelBuilder.Entity("Unity.AI.Domain.AIPrompt", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp1") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.HasKey("Id"); + + b.ToTable("AIPrompts", "AI"); + }); + + modelBuilder.Entity("Unity.AI.Domain.AIPromptVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("IsDeprecated") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("MaxTokens") + .HasColumnType("integer"); + + b.Property("Temperature") + .HasColumnType("double precision"); + + b.Property("PromptId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TargetModel") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetProvider") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.Property("SystemPrompt") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserPromptTemplate") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeveloperNotes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PromptId", "VersionNumber") + .IsUnique(); + + b.ToTable("AIPromptVersions", "AI"); + }); + + modelBuilder.Entity("Unity.AI.Domain.AIPromptVersion", b => + { + b.HasOne("Unity.AI.Domain.AIPrompt", "Prompt") + .WithMany("Versions") + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prompt"); + }); + + modelBuilder.Entity("Unity.AI.Domain.AIPrompt", b => + { + b.Navigation("Versions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index 5dc0832bb0..aac8eb62e4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -658,6 +658,101 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ApplicantTenantMaps", (string)null); }); + modelBuilder.Entity("Unity.GrantManager.GrantApplications.AIGenerationRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AttachmentId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FailureReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OperationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PromptVersion") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RequestKey"); + + b.HasIndex("ApplicationId", "OperationType", "Status"); + + b.ToTable("AIRequests", "AI"); + }); + modelBuilder.Entity("Unity.GrantManager.Integrations.CasClientCode", b => { b.Property("Id") @@ -1177,10 +1272,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("InboxMessages", (string)null); }); - modelBuilder.Entity("Unity.GrantManager.Messaging.OutboxMessage", b => - { - b.Property("Id") - .HasColumnType("uuid"); + modelBuilder.Entity("Unity.GrantManager.Messaging.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); b.Property("AckStatus") .IsRequired() @@ -1265,110 +1360,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Source", "Status"); - b.ToTable("OutboxMessages", (string)null); - }); - - modelBuilder.Entity("Unity.GrantManager.GrantApplications.AIGenerationRequest", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("ApplicationId") - .HasColumnType("uuid") - .HasColumnName("ApplicationId"); - - b.Property("AttachmentId") - .HasColumnType("uuid") - .HasColumnName("AttachmentId"); - - b.Property("CreationTime") - .HasColumnType("timestamp without time zone") - .HasColumnName("CreationTime"); - - b.Property("CreatorId") - .HasColumnType("uuid") - .HasColumnName("CreatorId"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .IsRequired() - .HasMaxLength(40) - .HasColumnType("character varying(40)") - .HasColumnName("ConcurrencyStamp"); - - b.Property("CompletedAt") - .HasColumnType("timestamp without time zone"); - - b.Property("DeleterId") - .HasColumnType("uuid") - .HasColumnName("DeleterId"); - - b.Property("DeletionTime") - .HasColumnType("timestamp without time zone") - .HasColumnName("DeletionTime"); - - b.Property("ExtraProperties") - .IsRequired() - .HasColumnType("text") - .HasColumnName("ExtraProperties"); - - b.Property("FailureReason") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("IsDeleted"); - - b.Property("LastModificationTime") - .HasColumnType("timestamp without time zone") - .HasColumnName("LastModificationTime"); - - b.Property("LastModifierId") - .HasColumnType("uuid") - .HasColumnName("LastModifierId"); - - b.Property("OperationType") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("PromptVersion") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("RequestKey") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("StartedAt") - .HasColumnType("timestamp without time zone"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("TenantId"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationId", "OperationType", "Status"); - - b.HasIndex("RequestKey"); - - b.ToTable("AIRequests", "AI"); - }); - - modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => - { - b.Property("Id") - .HasColumnType("uuid"); + b.ToTable("OutboxMessages", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); b.Property("CreationTime") .HasColumnType("timestamp without time zone") From 798cfc92186aae9bdeda738a78199fddabe9cac8 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 17 Apr 2026 16:59:05 -0700 Subject: [PATCH 14/47] AB#32451 consolidate AI queue entrypoints --- .../IApplicationAIGenerationQueue.cs | 6 + .../IGrantApplicationAppService.cs | 4 + .../ApplicationAIGenerationQueue.cs | 13 +- .../GenerateApplicationAnalysisJob.cs | 5 +- .../GenerateApplicationScoringJob.cs | 5 +- .../GenerateAttachmentSummaryJob.cs | 85 ++++++++++++ .../RunApplicationAIPipelineJob.cs | 8 +- .../GrantApplicationAppService.cs | 108 ++++++++++++++- .../Pages/GrantApplications/Details.js | 33 ++--- .../Pages/GrantApplications/ai-analysis.js | 43 ++++-- .../AssessmentScoresWidget/Default.js | 6 +- .../ChefsAttachments/ChefsAttachments.js | 49 +++---- .../Components/ReviewList/ReviewList.js | 128 +++++++++++++----- 13 files changed, 372 insertions(+), 121 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs index 3db64559f0..b8f059a463 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs @@ -5,5 +5,11 @@ namespace Unity.AI.Automation; public interface IApplicationAIGenerationQueue { +<<<<<<< HEAD +======= + Task QueueAttachmentSummariesAsync(Guid applicationId, IReadOnlyList attachmentIds, Guid? tenantId, string? promptVersion = null); + Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); +>>>>>>> 64123200c (AB#32451 consolidate AI queue entrypoints) Task QueueApplicationPipelineAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index 2d44ac5f64..c4fbe82eb0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -19,6 +19,10 @@ public interface IGrantApplicationAppService Task GetAsync(Guid id); Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null); + Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + Task QueueAttachmentSummaryAsync(Guid applicationId, string? promptVersion = null); + Task QueueAttachmentSummariesAsync(Guid applicationId, List attachmentIds, string? promptVersion = null); + Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null); Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null); Task QueueAIPipelineAsync(Guid applicationId, string? promptVersion = null); Task GetAccountCodingIdFromFormIdAsync(Guid formId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 9d5d24d473..50d6a32be7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -1,14 +1,13 @@ using System; using System.Linq; using System.Threading.Tasks; -using Medallion.Threading; using Unity.AI.Automation; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; +using Medallion.Threading; +using Volo.Abp.Domain.Repositories; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; -using Volo.Abp.DistributedLocking; -using Volo.Abp.Domain.Repositories; namespace Unity.GrantManager.GrantApplications.Automation; @@ -97,9 +96,11 @@ private async Task EnsureRequestAndEnqueueAsync( using (await requestLock.AcquireAsync()) { var query = await generationRequestRepository.GetQueryableAsync(); - var existing = query - .Where(x => x.RequestKey == requestKey - && (x.Status == AIGenerationRequestStatus.Queued || x.Status == AIGenerationRequestStatus.Running)) + var existingRequests = query.Where(x => + x.RequestKey == requestKey + && (x.Status == AIGenerationRequestStatus.Queued || x.Status == AIGenerationRequestStatus.Running)); + + var existing = existingRequests .OrderByDescending(x => x.CreationTime) .ThenByDescending(x => x.Id) .FirstOrDefault(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index 3ae554b632..4daaef6fa3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -76,9 +76,8 @@ private async Task MarkFailedAsync(string requestKey, string? failureReason) private async Task GetRequestAsync(string requestKey) { - var query = await generationRequestRepository.GetQueryableAsync(); - return query - .Where(x => x.RequestKey == requestKey) + var requests = await generationRequestRepository.GetListAsync(x => x.RequestKey == requestKey); + return requests .OrderByDescending(x => x.CreationTime) .ThenByDescending(x => x.Id) .FirstOrDefault(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index 4f4722a35b..ed168268c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -82,9 +82,8 @@ private async Task MarkFailedAsync(string requestKey, string? failureReason) private async Task GetRequestAsync(string requestKey) { - var query = await generationRequestRepository.GetQueryableAsync(); - return query - .Where(x => x.RequestKey == requestKey) + var requests = await generationRequestRepository.GetListAsync(x => x.RequestKey == requestKey); + return requests .OrderByDescending(x => x.CreationTime) .ThenByDescending(x => x.Id) .FirstOrDefault(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs new file mode 100644 index 0000000000..51adae95ba --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.AI.Operations; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateAttachmentSummaryJob( + IAttachmentSummaryService attachmentSummaryService, + IRepository generationRequestRepository, + ICurrentTenant currentTenant, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + await MarkRunningAsync(args.RequestKey); + try + { + logger.LogInformation( + "Executing AI attachment summary job for {AttachmentCount} attachment(s).", + args.AttachmentIds.Count); + await attachmentSummaryService.GenerateAndSaveAsync(args.AttachmentIds, args.PromptVersion); + await MarkCompletedAsync(args.RequestKey); + } + catch (Exception ex) + { + await MarkFailedAsync(args.RequestKey, ex.Message); + throw; + } + } + } + + private async Task MarkRunningAsync(string requestKey) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkRunning(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task MarkCompletedAsync(string requestKey) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkCompleted(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task MarkFailedAsync(string requestKey, string? failureReason) + { + var request = await GetRequestAsync(requestKey); + if (request == null) + { + return; + } + + request.MarkFailed(DateTime.UtcNow, failureReason); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + private async Task GetRequestAsync(string requestKey) + { + var requests = await generationRequestRepository.GetListAsync(x => x.RequestKey == requestKey); + return requests + .OrderByDescending(x => x.CreationTime) + .ThenByDescending(x => x.Id) + .FirstOrDefault(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs index df8a45ce39..cea059bf2d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs @@ -151,9 +151,11 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent private async Task GetRequestAsync(string requestKey, Guid applicationId) { - var query = await generationRequestRepository.GetQueryableAsync(); - return query - .Where(x => x.RequestKey == requestKey && x.ApplicationId == applicationId) + var requests = await generationRequestRepository.GetListAsync(x => + x.RequestKey == requestKey && + x.ApplicationId == applicationId); + + return requests .OrderByDescending(x => x.CreationTime) .ThenByDescending(x => x.Id) .FirstOrDefault(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 1f2ae9ea88..9d3a3ba4fc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -14,15 +14,15 @@ using System.Threading.Tasks; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; -using Unity.AI.Models; -using Unity.AI.Responses; -using Unity.GrantManager.Applicants; +using Unity.AI.Models; +using Unity.AI.Responses; +using Unity.GrantManager.Applicants; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; using Unity.GrantManager.Events; using Unity.GrantManager.Flex; using Unity.GrantManager.Identity; -using Unity.GrantManager.Payments; +using Unity.GrantManager.Payments; using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; using Unity.Payments.PaymentRequests; @@ -32,7 +32,8 @@ using Volo.Abp.Authorization; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities; -using Volo.Abp.Domain.Repositories; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications; @@ -46,12 +47,14 @@ public class GrantApplicationAppService( IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicantRepository applicantRepository, IApplicationFormRepository applicationFormRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IApplicantAgentRepository applicantAgentRepository, IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, IPaymentRequestAppService paymentRequestService, IApplicationAIGenerationQueue aiGenerationQueue, - IAIGenerationStatusAppService aiGenerationStatusAppService) + IAIGenerationStatusAppService aiGenerationStatusAppService, + IFeatureChecker featureChecker) : GrantManagerAppService, IGrantApplicationAppService { private static readonly JsonSerializerOptions AiAnalysisReadOptions = new() @@ -1035,7 +1038,71 @@ await LocalEventBus.PublishAsync( public async Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null) { - return await QueueAIPipelineAsync(applicationId, promptVersion); + await EnsureAIAnalysisEnabledAsync(); + return await QueueApplicationAnalysisAsync(applicationId, promptVersion); + } + + public async Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) + { + await EnsureAIAnalysisEnabledAsync(); + await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, CurrentTenant.Id, promptVersion); + + var request = await aiGenerationStatusAppService.GetLatestAsync( + applicationId, + AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, + promptVersion); + + return request ?? throw new UserFriendlyException("Unable to queue AI analysis request."); + } + + public async Task QueueAttachmentSummaryAsync(Guid applicationId, string? promptVersion = null) + { + await EnsureAttachmentSummariesEnabledAsync(); + var attachmentIds = await applicationChefsFileAttachmentRepository.GetListAsync(applicationId); + if (attachmentIds.Count == 0) + { + throw new UserFriendlyException("No attachments are available to summarize for this application."); + } + + await aiGenerationQueue.QueueAttachmentSummariesAsync(applicationId, attachmentIds.Select(a => a.Id).ToList(), CurrentTenant.Id, promptVersion); + + var request = await aiGenerationStatusAppService.GetLatestAsync( + applicationId, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, + promptVersion); + + return request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request."); + } + + public async Task QueueAttachmentSummariesAsync(Guid applicationId, List attachmentIds, string? promptVersion = null) + { + await EnsureAttachmentSummariesEnabledAsync(); + if (attachmentIds.Count == 0) + { + throw new UserFriendlyException("No attachments are available to summarize for this application."); + } + + await aiGenerationQueue.QueueAttachmentSummariesAsync(applicationId, attachmentIds, CurrentTenant.Id, promptVersion); + + var request = await aiGenerationStatusAppService.GetLatestAsync( + applicationId, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, + promptVersion); + + return request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request."); + } + + public async Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null) + { + await EnsureScoringEnabledAsync(); + await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, CurrentTenant.Id, promptVersion); + + var request = await aiGenerationStatusAppService.GetLatestAsync( + applicationId, + AIGenerationRequestKeyHelper.ApplicationScoringOperationType, + promptVersion); + + return request ?? throw new UserFriendlyException("Unable to queue AI scoring request."); } public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) @@ -1045,6 +1112,9 @@ public async Task QueueAIGenerationAsync(Guid applicatio public async Task QueueAIPipelineAsync(Guid applicationId, string? promptVersion = null) { + await EnsureAttachmentSummariesEnabledAsync(); + await EnsureAIAnalysisEnabledAsync(); + await EnsureScoringEnabledAsync(); await aiGenerationQueue.QueueApplicationPipelineAsync(applicationId, CurrentTenant.Id, promptVersion); var request = await aiGenerationStatusAppService.GetLatestAsync( @@ -1054,6 +1124,30 @@ public async Task QueueAIPipelineAsync(Guid applicationI return request ?? throw new UserFriendlyException("Unable to queue AI generation request."); } + + private async Task EnsureAttachmentSummariesEnabledAsync() + { + if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) + { + throw new UserFriendlyException("AI attachment summaries are not enabled."); + } + } + + private async Task EnsureAIAnalysisEnabledAsync() + { + if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis")) + { + throw new UserFriendlyException("AI application analysis is not enabled."); + } + } + + private async Task EnsureScoringEnabledAsync() + { + if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) + { + throw new UserFriendlyException("AI scoring is not enabled."); + } + } #endregion APPLICATION WORKFLOW public async Task> GetAllApplicationsAsync() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 216f16bdfe..314c82fb52 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -400,23 +400,20 @@ $(function () { stopAIGenerationPolling(); setAiGenerationStatus('Failed'); loadDevAiOutputs(); - restoreButton.html(originalHtml).prop('disabled', false); + restoreButton.html('Completed').prop('disabled', true); abp.message.error(request?.failureReason || 'AI generate all failed.'); return; } - if (!request || request.isActive === false || statusText === 'Completed') { - stopAIGenerationPolling(); - setAiGenerationStatus(''); - setDevAiOutputTimestamp('#analysisAiOutputTimestamp', request?.completedAt || request?.startedAt || null); - setDevAiOutputTimestamp('#scoringAiOutputTimestamp', request?.completedAt || request?.startedAt || null); - loadDevAiOutputs(); - restoreButton.html(originalHtml).prop('disabled', false); - if (statusText === 'Completed') { - abp.notify.success('AI generate all completed.'); - } - return; - } + if (!request || request.isActive === false || statusText === 'Completed') { + stopAIGenerationPolling(); + setAiGenerationStatus(''); + setDevAiOutputTimestamp('#analysisAiOutputTimestamp', request?.completedAt || request?.startedAt || null); + setDevAiOutputTimestamp('#scoringAiOutputTimestamp', request?.completedAt || request?.startedAt || null); + loadDevAiOutputs(); + restoreButton.html('Completed').prop('disabled', true); + return; + } aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); }) @@ -441,12 +438,11 @@ $(function () { restoreButton, originalHtml ); - abp.notify.success(queuedMessage); }) .fail(function() { setAiGenerationStatus(''); abp.message.error(failureMessage); - restoreButton.html(originalHtml).prop('disabled', false); + restoreButton.html('Completed').prop('disabled', true); }); } @@ -466,7 +462,7 @@ $(function () { setAiGenerationStatus('Queueing'); queueAIGenerationOperation( - () => unity.grantManager.attachments.attachmentSummary.generateAttachmentSummary(applicationId, promptVersion), + () => unity.grantManager.grantApplications.grantApplication.queueAttachmentSummary(applicationId, promptVersion), 'attachment-summary', 'AI attachment summary queued.', 'Failed to queue AI attachment summary. Please try again.', @@ -491,7 +487,7 @@ $(function () { setAiGenerationStatus('Queueing'); queueAIGenerationOperation( - () => unity.grantManager.grantApplications.applicationScoring.generateApplicationScoring(applicationId, promptVersion), + () => unity.grantManager.grantApplications.grantApplication.queueApplicationScoring(applicationId, promptVersion), 'application-scoring', 'AI application scoring queued.', 'Failed to queue AI application scoring. Please try again.', @@ -518,12 +514,11 @@ $(function () { setAiGenerationStatus('Queueing'); unity.grantManager.grantApplications.grantApplication - .queueAIGeneration(applicationId, promptVersion) + .queueAIPipeline(applicationId, promptVersion) .done(function(request) { const statusText = formatAiGenerationStatus(request?.status); setAiGenerationStatus(statusText || 'Queued'); pollAIGenerationStatus(applicationId, 'pipeline', promptVersion, $button, existingHtml); - abp.notify.success('AI generate all queued.'); }) .fail(function() { setAiGenerationStatus(''); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 2c8dfbcd8c..418c3d3ed0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -417,6 +417,8 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { const existingHtml = $button.html(); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; const aiAnalysisPollIntervalMs = 15000; + const aiAnalysisMaxPollFailures = 3; + const aiAnalysisMaxQueueWaitMs = 120000; if (!applicationId || $button.prop('disabled')) { return; @@ -427,6 +429,8 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { .prop('disabled', true); let aiAnalysisPollTimeoutId = null; + let aiAnalysisPollFailures = 0; + let aiAnalysisQueuedAt = Date.now(); const stopAIAnalysisPolling = function() { if (aiAnalysisPollTimeoutId) { clearTimeout(aiAnalysisPollTimeoutId); @@ -438,8 +442,8 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { unity.grantManager.grantApplications.grantApplication .getAIGenerationStatus(applicationId, 'application-analysis', promptVersion) .done(function(request) { + aiAnalysisPollFailures = 0; const statusText = request?.status ?? 'Queued'; - updateAnalysisTabStatus(statusText); if (statusText === 'Failed') { stopAIAnalysisPolling(); @@ -449,32 +453,47 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { return; } + if (Date.now() - aiAnalysisQueuedAt > aiAnalysisMaxQueueWaitMs) { + stopAIAnalysisPolling(); + $button.html(existingHtml).prop('disabled', false); + abp.message.error('AI analysis is still queued. Please try again later.'); + return; + } + if (!request || request.isActive === false || statusText === 'Completed') { stopAIAnalysisPolling(); loadAIAnalysis(); - $button.html(existingHtml).prop('disabled', false); - if (statusText === 'Completed') { - abp.notify.success('AI analysis completed.'); - } + $button.html('Completed').prop('disabled', true); return; } aiAnalysisPollTimeoutId = setTimeout(poll, aiAnalysisPollIntervalMs); }) - .fail(function() { + .fail(function(error) { + console.warn('Failed to poll AI analysis status.', error); + aiAnalysisPollFailures += 1; + + if (aiAnalysisPollFailures > aiAnalysisMaxPollFailures) { + stopAIAnalysisPolling(); + $button.html(existingHtml).prop('disabled', false); + abp.message.error('Unable to load AI analysis status. Please try again.'); + return; + } + aiAnalysisPollTimeoutId = setTimeout(poll, aiAnalysisPollIntervalMs); }); }; - unity.grantManager.grantApplications.applicationAnalysis - .generateApplicationAnalysis(applicationId, promptVersion) - .then(function() { - updateAnalysisTabStatus('Queued'); - abp.notify.success('AI analysis queued. Refresh later to see updated results.'); + unity.grantManager.grantApplications.grantApplication + .queueApplicationAnalysis(applicationId, promptVersion) + .done(function(request) { + aiAnalysisPollFailures = 0; + setAiGenerationStatus(formatAiGenerationStatus(request?.status) || 'Queued'); stopAIAnalysisPolling(); aiAnalysisPollTimeoutId = setTimeout(poll, 500); }) - .catch(function() { + .fail(function(error) { + console.error('Failed to queue AI analysis.', error); stopAIAnalysisPolling(); $button.html(existingHtml).prop('disabled', false); abp.message.error('Failed to queue AI analysis. Please try again.'); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index fb6bcdc61c..79bcdced25 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -597,16 +597,14 @@ function queueApplicationScoring(triggerButton = null) { .prop('disabled', true); unity.grantManager.grantApplications.applicationScoring - .generateApplicationScoring(applicationId, promptVersion) + .queueApplicationScoring(applicationId, promptVersion) .done(function () { - abp.notify.success('AI scoring queued. Refresh later to see updated results.'); + $button.html('Completed').prop('disabled', true); }) .fail(function () { abp.message.error( 'Failed to queue AI scoring. Please try again.' ); - }) - .always(function () { $button.html(existingHtml).prop('disabled', false); }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 5466a20147..7d80f14484 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -187,41 +187,24 @@ $(function () { const existingHTML = $activeButton.html(); - // Call the backend API - $.ajax({ - url: - '/api/app/attachment-summary/generate-attachment-summaries' + - '?promptVersion=' + - encodeURIComponent(promptVersion || ''), - data: JSON.stringify(attachmentIds), - contentType: 'application/json', - type: 'POST', - beforeSend: function () { - $activeButton - .html( - 'Queueing...' - ) - .prop('disabled', true); - }, - success: function (summaries) { - abp.notify.success( - 'AI summaries queued for ' + - summaries.length + - ' attachment(s). Refresh later to see updated results.' - ); - + $activeButton + .html( + 'Queueing...' + ) + .prop('disabled', true); + + unity.grantManager.grantApplications.grantApplication + .queueAttachmentSummaries(applicationId, attachmentIds, promptVersion) + .done(function () { + $activeButton.html('Completed').prop('disabled', true); resetAttachmentSelectionState(); - + }) + .fail(function (error) { + console.error('Error queueing AI summaries:', error); + abp.message.error('An error occurred while queueing AI summaries. Please try again.'); $activeButton.html(existingHTML).prop('disabled', false); - }, - error: function (error) { - console.error('Error generating AI summaries:', error); - abp.notify.error( - 'An error occurred while queueing AI summaries. Please try again.' - ); - $activeButton.html(existingHTML).prop('disabled', false); - }, - }); + }) + ; }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js index 7727bbedeb..0f378e12d1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js @@ -1,6 +1,6 @@ const l = abp.localization.getResource('GrantManager'); const pageApplicationId = decodeURIComponent(document.querySelector("#DetailsViewApplicationId").value); -const isAiScoringEnabled = document.querySelector("#ReviewListAIScoringEnabled")?.value === 'True'; +const isAiScoringEnabled = document.querySelector("#ReviewListAIScoringEnabled")?.value === 'True'; const canUseAiScoring = isAiScoringEnabled; const actionButtonConfigMap = { @@ -28,7 +28,7 @@ const finalApplicationStates = [ ]; $(function () { - const nullPlaceholder = '—'; + const nullPlaceholder = '—'; let inputAction = function (requestData, dataTableSettings) { const applicationId = pageApplicationId @@ -76,7 +76,7 @@ $(function () { } }); - $.fn.dataTable.Api.register('row().selectWithParams()', function (params) { + $.fn.dataTable.Api.register('row().selectWithParams()', function (params) { this.params = params; return this.select(); }); @@ -255,7 +255,7 @@ $(function () { $("#AdjudicationTeamLeadActionBar .dt-buttons").contents().unwrap(); updateAiActionButtonsVisibility(reviewListTable); - reviewListTable.on('select', function (e, dt, type, indexes) { + reviewListTable.on('select', function (e, dt, type, indexes) { handleRowSelection(e, dt, type, indexes, reviewListTable); }); @@ -263,9 +263,9 @@ $(function () { handleRowDeselection(e, dt, type, indexes, reviewListTable); }); - PubSub.subscribe('refresh_review_list', (msg, data) => { - refreshReviewList(data, reviewListTable); - }); + PubSub.subscribe('refresh_review_list', (msg, data) => { + refreshReviewList(data, reviewListTable); + }); PubSub.subscribe('refresh_review_list_without_sidepanel', (msg, data) => { refreshReviewList(data, reviewListTable, false); @@ -294,7 +294,7 @@ function handleRowSelection(e, dt, type, indexes, reviewListTable) { if (type === 'row') { let selectedData = reviewListTable.row(indexes).data(); document.getElementById("AssessmentId").value = selectedData.id; - if (refreshSidePanel) { + if (refreshSidePanel) { PubSub.publish('select_application_review', selectedData); PubSub.publish('refresh_assessment_attachment_list', selectedData.id); } @@ -455,29 +455,95 @@ function unityWorkflowButtonAction(e, dt, button, config) { } } -function generateAiButtonAction(e, dt, button, config) { - const $btn = $(this.node()); - const promptVersion = globalThis.getSelectedPromptVersion?.() || null; - - this.disable(); - $btn.html('Queueing...'); - - unity.grantManager.grantApplications.applicationScoring.generateApplicationScoring(pageApplicationId, promptVersion) - .done(function () { - abp.notify.success('AI scoring queued. Refresh later to see updated results.'); - }) - .fail(function () { - abp.message.error('Failed to queue AI scoring. Please try again.'); - }) - .always(() => { - this.enable(); - $btn.html(generateAiButtonText(null, null, null)); - }); -} - -function executeAssessmentAction(assessmentId, triggerAction) { - unity.grantManager.assessments.assessment.executeAssessmentAction(assessmentId, triggerAction, {}) - .then(function (result) { +function generateAiButtonAction(e, dt, button, config) { + const $button = button?.node ? $(button.node) : null; + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; + const aiGenerationPollIntervalMs = 15000; + let aiGenerationPollTimeoutId = null; + + if ($button?.length) { + $button.prop('disabled', true); + $button.html('Generating...'); + globalThis.AIGenerationButtonState?.setGenerating($button); + } + + const stopPolling = function () { + if (aiGenerationPollTimeoutId) { + clearTimeout(aiGenerationPollTimeoutId); + aiGenerationPollTimeoutId = null; + } + }; + + const poll = function () { + unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(pageApplicationId, 'application-scoring', promptVersion) + .done(function (request) { + const status = request?.status; + + if (status === 'Failed') { + stopPolling(); + abp.message.error(request?.failureReason || 'AI scoring failed.'); + if ($button?.length) { + globalThis.AIGenerationButtonState?.restore($button); + $button.prop('disabled', false); + $button.html(generateAiButtonText(null, null, null)); + } + return; + } + + if (!request || request.isActive === false || status === 'Completed') { + stopPolling(); + setReviewListAiButtonCompleted($button); + refreshReviewListAfterAiScoring(); + return; + } + + aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); + }) + .fail(function () { + aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); + }); + }; + + unity.grantManager.grantApplications.grantApplication.queueApplicationScoring(pageApplicationId, promptVersion) + .done(function (request) { + if (request?.status === 'Completed') { + setReviewListAiButtonCompleted($button); + refreshReviewListAfterAiScoring(); + return; + } + + aiGenerationPollTimeoutId = setTimeout(poll, 500); + }) + .fail(function () { + stopPolling(); + abp.message.error('Failed to queue AI scoring. Please try again.'); + if ($button?.length) { + globalThis.AIGenerationButtonState?.restore($button); + $button.prop('disabled', false); + $button.html(generateAiButtonText(null, null, null)); + } + }) + ; +} + +function setReviewListAiButtonCompleted($button) { + if (!$button?.length) { + return; + } + + globalThis.AIGenerationButtonState?.setCompleted($button); + $button.html('Completed').prop('disabled', true); +} + +function refreshReviewListAfterAiScoring() { + PubSub.publish('refresh_review_list', pageApplicationId); + PubSub.publish('refresh_assessment_scores', null); +} + +function executeAssessmentAction(assessmentId, triggerAction) { + unity.grantManager.assessments.assessment.executeAssessmentAction(assessmentId, triggerAction, {}) + .then(function (result) { PubSub.publish('assessment_action_completed'); PubSub.publish('refresh_review_list', assessmentId); abp.notify.success( From 7cf627998f380fbc257c36e7b493e67872f39197 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 17 Apr 2026 22:16:33 -0700 Subject: [PATCH 15/47] AB#32451 tighten AI generation flow --- .../IApplicationAIGenerationQueue.cs | 7 +- .../IAIGenerationStatusAppService.cs | 2 +- .../IGrantApplicationAppService.cs | 1 - .../AIGenerationStatusAppService.cs | 8 +- .../AIGenerationRequestJobBase.cs | 62 ++++ .../GenerateApplicationAnalysisJob.cs | 53 +--- .../GenerateApplicationScoringJob.cs | 53 +--- .../GenerateAttachmentSummaryJob.cs | 59 +--- .../RunApplicationAIPipelineJob.cs | 63 +--- ...teAIAssessmentOnScoringGeneratedHandler.cs | 9 +- .../GrantApplicationAppService.cs | 40 +-- .../Pages/GrantApplications/ai-analysis.js | 6 +- .../ChefsAttachments/ChefsAttachments.js | 290 +++++++----------- .../ChefsAttachments/Default.cshtml | 34 +- .../Automation/AIGenerationQueueTests.cs | 28 +- .../AIGenerationStatusAppServiceTests.cs | 44 +++ ...ssessmentOnScoringGeneratedHandlerTests.cs | 36 +-- 17 files changed, 304 insertions(+), 491 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobBase.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationStatusAppServiceTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs index b8f059a463..f7837b6c5a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs @@ -5,11 +5,8 @@ namespace Unity.AI.Automation; public interface IApplicationAIGenerationQueue { -<<<<<<< HEAD -======= - Task QueueAttachmentSummariesAsync(Guid applicationId, IReadOnlyList attachmentIds, Guid? tenantId, string? promptVersion = null); + Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); ->>>>>>> 64123200c (AB#32451 consolidate AI queue entrypoints) - Task QueueApplicationPipelineAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IAIGenerationStatusAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IAIGenerationStatusAppService.cs index 836d2e8ec7..7bc6594040 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IAIGenerationStatusAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IAIGenerationStatusAppService.cs @@ -6,5 +6,5 @@ namespace Unity.GrantManager.GrantApplications; public interface IAIGenerationStatusAppService : IApplicationService { - Task GetLatestAsync(Guid applicationId, string operationType, string? promptVersion = null); + Task GetLatestAsync(Guid applicationId, string operationType, string? promptVersion = null, Guid? tenantId = null); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index c4fbe82eb0..0d1dc61543 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -21,7 +21,6 @@ public interface IGrantApplicationAppService Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null); Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); Task QueueAttachmentSummaryAsync(Guid applicationId, string? promptVersion = null); - Task QueueAttachmentSummariesAsync(Guid applicationId, List attachmentIds, string? promptVersion = null); Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null); Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null); Task QueueAIPipelineAsync(Guid applicationId, string? promptVersion = null); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/AIGenerationStatusAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/AIGenerationStatusAppService.cs index 22749f0c55..60781d99c1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/AIGenerationStatusAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/AIGenerationStatusAppService.cs @@ -4,22 +4,26 @@ using Unity.GrantManager.GrantApplications; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; using Volo.Abp.Threading; namespace Unity.GrantManager.GrantApplications; public class AIGenerationStatusAppService( - IRepository generationRequestRepository) + IRepository generationRequestRepository, + ICurrentTenant currentTenant) : ApplicationService, IAIGenerationStatusAppService { - public virtual async Task GetLatestAsync(Guid applicationId, string operationType, string? promptVersion = null) + public virtual async Task GetLatestAsync(Guid applicationId, string operationType, string? promptVersion = null, Guid? tenantId = null) { var query = await generationRequestRepository.GetQueryableAsync(); + var resolvedTenantId = tenantId ?? currentTenant.Id; var item = await AsyncExecuter.FirstOrDefaultAsync( query.Where(x => x.ApplicationId == applicationId && x.OperationType == operationType && + x.TenantId == resolvedTenantId && (promptVersion == null || x.PromptVersion == promptVersion)) .OrderByDescending(x => x.CreationTime) .ThenByDescending(x => x.Id)); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobBase.cs new file mode 100644 index 0000000000..a3d063c002 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobBase.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public static class AIGenerationRequestJobBase +{ + public static async Task MarkRunningAsync( + IRepository generationRequestRepository, + AIGenerationRequest? request) + { + if (request == null) + { + return; + } + + request.MarkRunning(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + public static async Task MarkCompletedAsync( + IRepository generationRequestRepository, + AIGenerationRequest? request) + { + if (request == null) + { + return; + } + + request.MarkCompleted(DateTime.UtcNow); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + public static async Task MarkFailedAsync( + IRepository generationRequestRepository, + AIGenerationRequest? request, + string? failureReason) + { + if (request == null) + { + return; + } + + request.MarkFailed(DateTime.UtcNow, failureReason); + await generationRequestRepository.UpdateAsync(request, autoSave: true); + } + + public static async Task GetLatestRequestAsync( + IRepository generationRequestRepository, + Expression> predicate) + { + var requests = await generationRequestRepository.GetListAsync(predicate); + return requests + .OrderByDescending(x => x.CreationTime) + .ThenByDescending(x => x.Id) + .FirstOrDefault(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index 4daaef6fa3..5103244300 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using System; -using System.Linq; using System.Threading.Tasks; using Unity.AI.Operations; using Unity.GrantManager.GrantApplications; @@ -21,65 +20,21 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob { using (currentTenant.Change(args.TenantId)) { - await MarkRunningAsync(args.RequestKey); + var request = await AIGenerationRequestJobBase.GetLatestRequestAsync(generationRequestRepository, x => x.RequestKey == args.RequestKey); + await AIGenerationRequestJobBase.MarkRunningAsync(generationRequestRepository, request); try { logger.LogInformation("Executing AI application analysis job for application {ApplicationId}.", args.ApplicationId); await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId); - await MarkCompletedAsync(args.RequestKey); + await AIGenerationRequestJobBase.MarkCompletedAsync(generationRequestRepository, request); } catch (Exception ex) { - await MarkFailedAsync(args.RequestKey, ex.Message); + await AIGenerationRequestJobBase.MarkFailedAsync(generationRequestRepository, request, ex.Message); throw; } } } - - private async Task MarkRunningAsync(string requestKey) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkRunning(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task MarkCompletedAsync(string requestKey) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkCompleted(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task MarkFailedAsync(string requestKey, string? failureReason) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkFailed(DateTime.UtcNow, failureReason); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task GetRequestAsync(string requestKey) - { - var requests = await generationRequestRepository.GetListAsync(x => x.RequestKey == requestKey); - return requests - .OrderByDescending(x => x.CreationTime) - .ThenByDescending(x => x.Id) - .FirstOrDefault(); - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index ed168268c2..a0cd1736c4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using System; -using System.Linq; using System.Threading.Tasks; using Unity.AI.Operations; using Unity.GrantManager.GrantApplications; @@ -24,7 +23,8 @@ public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobA { using (currentTenant.Change(args.TenantId)) { - await MarkRunningAsync(args.RequestKey); + var request = await AIGenerationRequestJobBase.GetLatestRequestAsync(generationRequestRepository, x => x.RequestKey == args.RequestKey); + await AIGenerationRequestJobBase.MarkRunningAsync(generationRequestRepository, request); try { logger.LogInformation("Executing AI application scoring job for application {ApplicationId}.", args.ApplicationId); @@ -34,58 +34,13 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent ApplicationId = args.ApplicationId }); - await MarkCompletedAsync(args.RequestKey); + await AIGenerationRequestJobBase.MarkCompletedAsync(generationRequestRepository, request); } catch (Exception ex) { - await MarkFailedAsync(args.RequestKey, ex.Message); + await AIGenerationRequestJobBase.MarkFailedAsync(generationRequestRepository, request, ex.Message); throw; } } } - - private async Task MarkRunningAsync(string requestKey) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkRunning(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task MarkCompletedAsync(string requestKey) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkCompleted(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task MarkFailedAsync(string requestKey, string? failureReason) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkFailed(DateTime.UtcNow, failureReason); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task GetRequestAsync(string requestKey) - { - var requests = await generationRequestRepository.GetListAsync(x => x.RequestKey == requestKey); - return requests - .OrderByDescending(x => x.CreationTime) - .ThenByDescending(x => x.Id) - .FirstOrDefault(); - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs index 51adae95ba..a3cbf6dacd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using System; -using System.Linq; using System.Threading.Tasks; using Unity.AI.Operations; using Unity.GrantManager.GrantApplications; @@ -21,65 +20,21 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr { using (currentTenant.Change(args.TenantId)) { - await MarkRunningAsync(args.RequestKey); + var request = await AIGenerationRequestJobBase.GetLatestRequestAsync(generationRequestRepository, x => x.RequestKey == args.RequestKey); + await AIGenerationRequestJobBase.MarkRunningAsync(generationRequestRepository, request); try { logger.LogInformation( - "Executing AI attachment summary job for {AttachmentCount} attachment(s).", - args.AttachmentIds.Count); - await attachmentSummaryService.GenerateAndSaveAsync(args.AttachmentIds, args.PromptVersion); - await MarkCompletedAsync(args.RequestKey); + "Executing AI attachment summary job for application {ApplicationId}.", + args.ApplicationId); + await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); + await AIGenerationRequestJobBase.MarkCompletedAsync(generationRequestRepository, request); } catch (Exception ex) { - await MarkFailedAsync(args.RequestKey, ex.Message); + await AIGenerationRequestJobBase.MarkFailedAsync(generationRequestRepository, request, ex.Message); throw; } } } - - private async Task MarkRunningAsync(string requestKey) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkRunning(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task MarkCompletedAsync(string requestKey) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkCompleted(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task MarkFailedAsync(string requestKey, string? failureReason) - { - var request = await GetRequestAsync(requestKey); - if (request == null) - { - return; - } - - request.MarkFailed(DateTime.UtcNow, failureReason); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task GetRequestAsync(string requestKey) - { - var requests = await generationRequestRepository.GetListAsync(x => x.RequestKey == requestKey); - return requests - .OrderByDescending(x => x.CreationTime) - .ThenByDescending(x => x.Id) - .FirstOrDefault(); - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs index cea059bf2d..68863446d7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs @@ -44,7 +44,9 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) { try { - var request = await GetRequestAsync(requestKey, args.ApplicationId); + var request = await AIGenerationRequestJobBase.GetLatestRequestAsync(generationRequestRepository, x => + x.RequestKey == requestKey && + x.ApplicationId == args.ApplicationId); if (request != null && request.Status == AIGenerationRequestStatus.Completed) { @@ -52,11 +54,7 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) return; } - if (request != null) - { - request.MarkRunning(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } + await AIGenerationRequestJobBase.MarkRunningAsync(generationRequestRepository, request); var application = await applicationRepository.GetAsync(args.ApplicationId); var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); @@ -64,7 +62,7 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) if (!applicationForm.AutomaticallyGenerateAIAnalysis) { logger.LogDebug("Automatic AI analysis is disabled at form level for application {ApplicationId}, skipping intake pipeline.", args.ApplicationId); - await MarkCompletedAsync(requestKey, args.ApplicationId); + await AIGenerationRequestJobBase.MarkCompletedAsync(generationRequestRepository, request); return; } @@ -74,14 +72,14 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) if (!attachmentSummariesEnabled && !applicationAnalysisEnabled && !scoringEnabled) { logger.LogDebug("All AI features are disabled, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); - await MarkCompletedAsync(requestKey, args.ApplicationId); + await AIGenerationRequestJobBase.MarkCompletedAsync(generationRequestRepository, request); return; } if (!await aiService.IsAvailableAsync()) { logger.LogWarning("AI service is not available, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); - await MarkFailedAsync(requestKey, args.ApplicationId, "AI service is not available."); + await AIGenerationRequestJobBase.MarkFailedAsync(generationRequestRepository, request, "AI service is not available."); return; } @@ -128,60 +126,27 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent if (scoringException != null) { - await MarkFailedAsync(requestKey, args.ApplicationId, scoringException.Message); + await AIGenerationRequestJobBase.MarkFailedAsync(generationRequestRepository, request, scoringException.Message); throw scoringException; } if (analysisException != null) { - await MarkFailedAsync(requestKey, args.ApplicationId, analysisException.Message); + await AIGenerationRequestJobBase.MarkFailedAsync(generationRequestRepository, request, analysisException.Message); throw analysisException; } - await MarkCompletedAsync(requestKey, args.ApplicationId); + await AIGenerationRequestJobBase.MarkCompletedAsync(generationRequestRepository, request); } catch (Exception ex) { - await MarkFailedAsync(requestKey, args.ApplicationId, ex.Message); + var request = await AIGenerationRequestJobBase.GetLatestRequestAsync(generationRequestRepository, x => + x.RequestKey == requestKey && + x.ApplicationId == args.ApplicationId); + await AIGenerationRequestJobBase.MarkFailedAsync(generationRequestRepository, request, ex.Message); throw; } } } } - - private async Task GetRequestAsync(string requestKey, Guid applicationId) - { - var requests = await generationRequestRepository.GetListAsync(x => - x.RequestKey == requestKey && - x.ApplicationId == applicationId); - - return requests - .OrderByDescending(x => x.CreationTime) - .ThenByDescending(x => x.Id) - .FirstOrDefault(); - } - - private async Task MarkCompletedAsync(string requestKey, Guid applicationId) - { - var request = await GetRequestAsync(requestKey, applicationId); - if (request == null) - { - return; - } - - request.MarkCompleted(DateTime.UtcNow); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } - - private async Task MarkFailedAsync(string requestKey, Guid applicationId, string? failureReason) - { - var request = await GetRequestAsync(requestKey, applicationId); - if (request == null) - { - return; - } - - request.MarkFailed(DateTime.UtcNow, failureReason); - await generationRequestRepository.UpdateAsync(request, autoSave: true); - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/CreateAIAssessmentOnScoringGeneratedHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/CreateAIAssessmentOnScoringGeneratedHandler.cs index c93eff4685..4f8da39ef8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/CreateAIAssessmentOnScoringGeneratedHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/CreateAIAssessmentOnScoringGeneratedHandler.cs @@ -1,21 +1,18 @@ using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; -using Unity.AI.Settings; using Unity.GrantManager.Applications; using Unity.GrantManager.Assessments; using Unity.GrantManager.GrantApplications.Automation.Events; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus; using Volo.Abp.Features; -using Volo.Abp.Settings; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantApplications.Automation.Handlers; public class CreateAIAssessmentOnScoringGeneratedHandler( AssessmentManager assessmentManager, IApplicationRepository applicationRepository, IFeatureChecker featureChecker, - ISettingProvider settingProvider, IUnitOfWorkManager unitOfWorkManager, ILogger logger) : ILocalEventHandler, ITransientDependency { @@ -26,10 +23,6 @@ public async Task HandleEventAsync(ApplicationAIScoringGeneratedEvent eventData) logger.LogWarning("Event data or application ID is null in CreateAIAssessmentOnScoringGeneratedHandler."); return; } - if (!await settingProvider.GetAsync(AISettings.AutomaticGenerationEnabled, defaultValue: false)) - { - return; - } if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) { return; @@ -47,4 +40,4 @@ public async Task HandleEventAsync(ApplicationAIScoringGeneratedEvent eventData) logger.LogError(ex, "Error creating AI assessment for application {ApplicationId}.", eventData.ApplicationId); } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 9d3a3ba4fc..e5af5a2805 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -1050,7 +1050,8 @@ public async Task QueueApplicationAnalysisAsync(Guid app var request = await aiGenerationStatusAppService.GetLatestAsync( applicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, - promptVersion); + promptVersion, + CurrentTenant.Id); return request ?? throw new UserFriendlyException("Unable to queue AI analysis request."); } @@ -1058,36 +1059,13 @@ public async Task QueueApplicationAnalysisAsync(Guid app public async Task QueueAttachmentSummaryAsync(Guid applicationId, string? promptVersion = null) { await EnsureAttachmentSummariesEnabledAsync(); - var attachmentIds = await applicationChefsFileAttachmentRepository.GetListAsync(applicationId); - if (attachmentIds.Count == 0) - { - throw new UserFriendlyException("No attachments are available to summarize for this application."); - } - - await aiGenerationQueue.QueueAttachmentSummariesAsync(applicationId, attachmentIds.Select(a => a.Id).ToList(), CurrentTenant.Id, promptVersion); - - var request = await aiGenerationStatusAppService.GetLatestAsync( - applicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, - promptVersion); - - return request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request."); - } - - public async Task QueueAttachmentSummariesAsync(Guid applicationId, List attachmentIds, string? promptVersion = null) - { - await EnsureAttachmentSummariesEnabledAsync(); - if (attachmentIds.Count == 0) - { - throw new UserFriendlyException("No attachments are available to summarize for this application."); - } - - await aiGenerationQueue.QueueAttachmentSummariesAsync(applicationId, attachmentIds, CurrentTenant.Id, promptVersion); + await aiGenerationQueue.QueueAttachmentSummaryAsync(applicationId, CurrentTenant.Id, promptVersion); var request = await aiGenerationStatusAppService.GetLatestAsync( applicationId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, - promptVersion); + promptVersion, + CurrentTenant.Id); return request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request."); } @@ -1100,14 +1078,15 @@ public async Task QueueApplicationScoringAsync(Guid appl var request = await aiGenerationStatusAppService.GetLatestAsync( applicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType, - promptVersion); + promptVersion, + CurrentTenant.Id); return request ?? throw new UserFriendlyException("Unable to queue AI scoring request."); } public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) { - return await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, promptVersion); + return await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, promptVersion, CurrentTenant.Id); } public async Task QueueAIPipelineAsync(Guid applicationId, string? promptVersion = null) @@ -1120,7 +1099,8 @@ public async Task QueueAIPipelineAsync(Guid applicationI var request = await aiGenerationStatusAppService.GetLatestAsync( applicationId, AIGenerationRequestKeyHelper.PipelineOperationType, - promptVersion); + promptVersion, + CurrentTenant.Id); return request ?? throw new UserFriendlyException("Unable to queue AI generation request."); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 418c3d3ed0..8f9f56b79b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -488,7 +488,7 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { .queueApplicationAnalysis(applicationId, promptVersion) .done(function(request) { aiAnalysisPollFailures = 0; - setAiGenerationStatus(formatAiGenerationStatus(request?.status) || 'Queued'); + setAIGenerationStatus(formatAiGenerationStatus(request?.status) || 'Queued'); stopAIAnalysisPolling(); aiAnalysisPollTimeoutId = setTimeout(poll, 500); }) @@ -500,6 +500,10 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { }); } +function setAIGenerationStatus(value) { + $('#aiGenerationStatus').text(value ? `(${value})` : ''); +} + function loadAIAnalysis() { if ($('#AIAnalysisFeatureEnabled').val() === 'False') { return; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 7d80f14484..d07b5a61a6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -1,20 +1,20 @@ // Note: File depends on Unity.GrantManager.Web\Views\Shared\Components\_Shared\Attachments.js $(function () { - globalThis.queueAttachmentSummary = function(triggerButton = null) { + globalThis.queueAttachmentSummary = function (triggerButton = null) { $('#generateAiSummaries') .data('trigger-button', triggerButton || null) .trigger('click'); }; - const downloadAll = $('#downloadSelected'); + const downloadAll = $('#downloadAll'); const dt = $('#ChefsAttachmentsTable'); let chefsDataTable; - let selectedAtttachments = []; + const aiSummaryPollIntervalMs = 15000; + let aiSummaryPollTimeoutId = null; const nullPlaceholder = '—'; let inputAction = function (requestData, dataTableSettings) { - const urlParams = new URL(window.location.toLocaleString()) - .searchParams; + const urlParams = new URL(window.location.toLocaleString()).searchParams; const applicationId = urlParams.get('ApplicationId'); return applicationId; }; @@ -31,15 +31,8 @@ $(function () { }); }, 10); - if (result.length === 0 || selectedAtttachments.length === 0) { - $(downloadAll).prop('disabled', true); + $(downloadAll).prop('disabled', result.length === 0); - if (document.getElementById('generateAiSummaries')) { - $generateAISummariesButton.prop('disabled', true); - } - } - - // Check if any attachments have AI summaries and enable/disable toggle button const hasAISummaries = result.some( (item) => item.aiSummary && item.aiSummary.trim() !== '' ); @@ -49,7 +42,7 @@ $(function () { if (!hasAISummaries) { $toggleButton.attr('title', 'No AI summaries available'); } else { - $toggleButton.attr('title', 'Toggle AI Summaries'); + $toggleButton.attr('title', 'Show AI Summaries'); } } } @@ -60,13 +53,25 @@ $(function () { function getColumns() { return [ - getSelectColumn('Select Attachment', 'rowCount', 'chefs-files'), + getChefsIconColumn(), getChefsFileNameColumn(), getChefsLabelColumn(), getChefsFileDownloadColumn(), ]; } + function getChefsIconColumn() { + return { + title: '', + width: '40px', + className: 'text-center', + render: function () { + return ''; + }, + orderable: false, + }; + } + function getChefsFileNameColumn() { return { title: 'Document Name', @@ -91,8 +96,6 @@ $(function () { }; } - - let formatItems = function (items) { const newData = items.map((item, index) => { return { @@ -115,10 +118,6 @@ $(function () { scrollCollapse: false, processing: true, autoWidth: true, - select: { - style: 'multiple', - selector: 'td:not(:nth-child(4))', - }, ajax: abp.libs.datatables.createAjax( unity.grantManager.attachments.attachment .getApplicationChefsFileAttachments, @@ -152,59 +151,87 @@ $(function () { chefsDataTable.ajax.reload(); }); - //Generate AI summaries for attachments + // Generate AI summaries for the current application attachments. const $generateAISummariesButton = $('#generateAiSummaries'); if ($generateAISummariesButton.length > 0) { - function resetAttachmentSelectionState() { - selectedAtttachments = []; - $('.select-all-chefs-files').prop('checked', false); - chefsDataTable.$('.chkbox').prop('checked', false); - $(downloadAll).prop('disabled', true); - $generateAISummariesButton.prop('disabled', true); - } - $generateAISummariesButton.on('click', function () { const $button = $(this); const triggerButton = $button.data('trigger-button'); const $activeButton = triggerButton ? $(triggerButton) : $button; - const rowsToProcess = triggerButton - ? chefsDataTable.rows().data() - : chefsDataTable.rows({ selected: true }).data(); + const applicationId = new URL(window.location.toLocaleString()).searchParams.get('ApplicationId'); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; $button.removeData('trigger-button'); - if (rowsToProcess.length === 0) { - abp.message.warn( - triggerButton - ? 'No attachments were found to generate summaries for.' - : 'Please select at least one attachment to generate summaries.' - ); + if (!applicationId) { + abp.message.warn('No application was found for attachment summary generation.'); return; } - const attachmentIds = rowsToProcess.toArray().map((row) => row.id); - const existingHTML = $activeButton.html(); + const stopPolling = function () { + if (aiSummaryPollTimeoutId) { + clearTimeout(aiSummaryPollTimeoutId); + aiSummaryPollTimeoutId = null; + } + }; + const poll = function () { + unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(applicationId, 'attachment-summary', promptVersion) + .done(function (request) { + const status = request?.status; + + if (status === 'Failed') { + stopPolling(); + abp.message.error(request?.failureReason || 'AI attachment summary generation failed.'); + globalThis.AIGenerationButtonState?.restore($activeButton); + $activeButton.html(existingHTML).prop('disabled', false); + return; + } + + if (!request || request.isActive === false || status === 'Completed') { + stopPolling(); + globalThis.AIGenerationButtonState?.setCompleted($activeButton); + $activeButton.html('Completed').prop('disabled', true); + chefsDataTable.ajax.reload(); + return; + } + + aiSummaryPollTimeoutId = setTimeout(poll, aiSummaryPollIntervalMs); + }) + .fail(function () { + aiSummaryPollTimeoutId = setTimeout(poll, aiSummaryPollIntervalMs); + }); + }; $activeButton .html( - 'Queueing...' + 'Generating...' ) .prop('disabled', true); + globalThis.AIGenerationButtonState?.setGenerating($activeButton); unity.grantManager.grantApplications.grantApplication - .queueAttachmentSummaries(applicationId, attachmentIds, promptVersion) - .done(function () { - $activeButton.html('Completed').prop('disabled', true); - resetAttachmentSelectionState(); + .queueAttachmentSummary(applicationId, promptVersion) + .done(function (request) { + if (request?.status === 'Completed') { + $activeButton.html('Completed').prop('disabled', true); + chefsDataTable.ajax.reload(); + return; + } + + aiSummaryPollTimeoutId = setTimeout(poll, 500); }) .fail(function (error) { + if (aiSummaryPollTimeoutId) { + clearTimeout(aiSummaryPollTimeoutId); + aiSummaryPollTimeoutId = null; + } console.error('Error queueing AI summaries:', error); abp.message.error('An error occurred while queueing AI summaries. Please try again.'); + globalThis.AIGenerationButtonState?.restore($activeButton); $activeButton.html(existingHTML).prop('disabled', false); - }) - ; + }); }); } @@ -218,28 +245,24 @@ $(function () { const $icon = $button.find('i'); const $label = $button.find('.toggle-ai-summaries-label'); - // Don't do anything if button is disabled if ($button.prop('disabled')) { return; } if (allAISummariesExpanded) { - // Collapse all chefsDataTable.rows().every(function () { const row = this; if (row.child.isShown()) { const $childRow = $(row.child()); const $summaryRow = $childRow.find('.ai-summary-row'); - // Add fade-out class to the summary row $summaryRow.removeClass('fade-in').addClass('fade-out'); - // Wait for animation to complete before hiding setTimeout(function () { row.child.hide(); $(row.node()).removeClass('shown'); $summaryRow.removeClass('fade-out'); - }, 500); // Match animation duration + }, 500); } }); $icon.removeClass('fa-chevron-up').addClass('fa-chevron-down'); @@ -247,26 +270,19 @@ $(function () { $button.attr('title', 'Show AI Summaries'); allAISummariesExpanded = false; } else { - // Expand all chefsDataTable.rows().every(function () { const row = this; const rowData = row.data(); - // Only expand if there's an AI summary if (rowData.aiSummary && rowData.aiSummary.trim() !== '') { - // Create the summary HTML const summaryHtml = formatAISummary(rowData); - // Show the child row row.child(summaryHtml).show(); $(row.node()).addClass('shown'); - // Add fade-in animation after DOM is ready setTimeout(function () { const $childRow = $(row.child()); - $childRow - .find('.ai-summary-row') - .addClass('fade-in'); + $childRow.find('.ai-summary-row').addClass('fade-in'); }, 10); } }); @@ -278,7 +294,6 @@ $(function () { }); } - // Reset AI summary expansion state when table is reloaded chefsDataTable.on('draw.dt', function () { if (allAISummariesExpanded) { const $button = $('#toggleAllAISummaries'); @@ -305,78 +320,6 @@ $(function () { ); } - chefsDataTable.on('select', function (e, dt, type, indexes) { - if (indexes?.length) { - indexes.forEach((index) => { - $(chefsDataTable.row(index).node()).find('.chkbox').prop('checked', true); - if (chefsDataTable.$('.chkbox:checked').length == chefsDataTable.$('.chkbox').length) { - $('.select-all-chefs-files').prop('checked', true); - } - selectAttachment(type, index, 'select_chefs_file'); - }); - } - }); - - chefsDataTable.on('deselect', function (e, dt, type, indexes) { - if (indexes?.length) { - indexes.forEach((index) => { - $(chefsDataTable.row(index).node()).find('.chkbox').prop('checked', false); - if (chefsDataTable.$('.chkbox:checked').length != chefsDataTable.$('.chkbox').length) { - $('.select-all-chefs-files').prop('checked', false); - } - selectAttachment(type, index, 'deselect_chefs_file'); - }); - } - }); - - function selectAttachment(type, indexes, action) { - if (type === 'row') { - let data = chefsDataTable.row(indexes).data(); - PubSub.publish(action, data); - - if (action == 'select_chefs_file') { - const found = selectedAtttachments.some( - (item) => item.chefsFileId === data.chefsFileId - ); - if (!found) { - selectedAtttachments.push({ - FormSubmissionId: data.chefsSubmissionId, - ChefsFileId: data.chefsFileId, - Filename: data.fileName, - }); - } - } else if (action == 'deselect_chefs_file') { - const filtedItems = selectedAtttachments.filter( - (item) => item.ChefsFileId !== data.chefsFileId - ); - selectedAtttachments = filtedItems; - } - - if (selectedAtttachments.length > 0) { - $(downloadAll).prop('disabled', false); - } else { - $(downloadAll).prop('disabled', true); - } - - if ( - document.getElementById('generateAiSummaries') && - selectedAtttachments.length > 0 - ) { - $generateAISummariesButton.prop('disabled', false); - } else { - $generateAISummariesButton.prop('disabled', true); - } - } - } - - $('.select-all-chefs-files').on('click', function () { - if ($(this).is(':checked')) { - chefsDataTable.rows({ page: 'current' }).select(); - } else { - chefsDataTable.rows({ page: 'current' }).deselect(); - } - }); - $('#resyncSubmissionAttachments').on('click', function () { let applicationId = document.getElementById( 'AssessmentResultViewApplicationId' @@ -385,9 +328,7 @@ $(function () { unity.grantManager.attachments.attachment .resyncSubmissionAttachments(applicationId) .done(function () { - abp.notify.success( - 'Submission Attachment/s has been resynced.' - ); + abp.notify.success('Submission Attachment/s has been resynced.'); chefsDataTable.ajax.reload(); chefsDataTable.columns.adjust(); }); @@ -406,17 +347,19 @@ $(function () { const _this = $(this); const existingHTML = _this.html(); const zip = new JSZip(); - const tempFiles = selectedAtttachments; + const tempFiles = chefsDataTable.rows({ search: 'applied' }).data().toArray().map((row) => ({ + FormSubmissionId: row.chefsSubmissionId, + ChefsFileId: row.chefsFileId, + Filename: row.fileName, + })); if (tempFiles.length > 0) { - //Calls an endpoint $.ajax({ url: '/api/app/attachment/chefs/download-all', data: JSON.stringify(tempFiles), contentType: 'application/json', type: 'POST', beforeSend: function () { - //Add loading spinner $(_this) .html( '
Downloading...
' @@ -430,9 +373,7 @@ $(function () { }); }); - zip.generateAsync({ type: 'blob' }).then(function ( - content - ) { + zip.generateAsync({ type: 'blob' }).then(function (content) { const link = document.createElement('a'); link.href = URL.createObjectURL(content); link.download = `${refNo}-All_Attachments.zip`; @@ -443,7 +384,6 @@ $(function () { '', 'The files have been downloaded successfully.' ); - //show original HTML and enable $(_this).html(existingHTML).prop('disabled', false); }, error: function (error) { @@ -472,35 +412,35 @@ function getChefsFileDownloadColumn() { className: 'text-nowrap', render: function (data, type, full, meta) { let submissionId = encodeURIComponent(full.chefsSubmissionId); - let fileId = encodeURIComponent(data); - let fileName = full.fileName; - let displayName = full.displayName || full.fileName; + let fileId = encodeURIComponent(data); + let fileName = full.fileName; + let displayName = full.displayName || full.fileName; let html = ''; return html; }, @@ -526,7 +466,6 @@ function downloadChefsFile(event) { chefsFileName, }); - //Calls an endpoint $.ajax({ url: '/api/app/attachment/chefs/' + @@ -537,7 +476,6 @@ function downloadChefsFile(event) { chefsFileName, type: 'GET', success: function (data) { - // Download file by navigating to the endpoint const downloadUrl = '/api/app/attachment/chefs/' + encodeURIComponent(chefsSubmissionId) + @@ -546,7 +484,6 @@ function downloadChefsFile(event) { '/' + encodeURIComponent(chefsFileName); - // Create a temporary link and trigger download const link = document.createElement('a'); link.href = downloadUrl; link.download = chefsFileName; @@ -554,10 +491,7 @@ function downloadChefsFile(event) { document.body.appendChild(link); link.click(); document.body.removeChild(link); - abp.notify.success( - '', - 'The file has been downloaded successfully.' - ); + abp.notify.success('', 'The file has been downloaded successfully.'); }, error: function (error) { console.log('Error downloading CHEFS file:', error); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index e59115427a..7f4b892de0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -3,29 +3,23 @@ @inject IStringLocalizer L
-
-
-
Submission Attachments
-
-
- - - -
+
+
Submission Attachments
-
+
+ + + @if (ViewBag.IsAIAttachmentSummariesGenerateEnabled) { - - - - @if (ViewBag.IsAIAttachmentSummariesGenerateEnabled) - { - - } - @if (ViewBag.IsAIAttachmentSummariesEnabled) - { - - } + +
+ @if (ViewBag.IsAIAttachmentSummariesGenerateEnabled || ViewBag.IsAIAttachmentSummariesEnabled) + { +
+ @if (ViewBag.IsAIAttachmentSummariesGenerateEnabled) + { + + } + @if (ViewBag.IsAIAttachmentSummariesEnabled) + { + + } +
+ }
From 55006869e7b398045581be1f6bfdc59c973a799d Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 27 Apr 2026 14:26:30 -0700 Subject: [PATCH 25/47] AB#32451 clean up chefs attachments polling helpers --- .../ChefsAttachments/ChefsAttachments.js | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 9fd948d6d0..1453988905 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -14,7 +14,7 @@ $(function () { const nullPlaceholder = '—'; let inputAction = function (requestData, dataTableSettings) { - const urlParams = new URL(window.location.toLocaleString()).searchParams; + const urlParams = new URL(globalThis.location.toLocaleString()).searchParams; const applicationId = urlParams.get('ApplicationId'); return applicationId; }; @@ -60,18 +60,6 @@ $(function () { ]; } - function getChefsIconColumn() { - return { - title: '', - width: '40px', - className: 'text-center', - render: function () { - return ''; - }, - orderable: false, - }; - } - function getChefsFileNameColumn() { return { title: 'Document Name', @@ -153,12 +141,18 @@ $(function () { // Generate AI summaries for the current application attachments. const $generateAISummariesButton = $('#generateAiSummaries'); + const stopPolling = function () { + if (aiSummaryPollTimeoutId) { + clearTimeout(aiSummaryPollTimeoutId); + aiSummaryPollTimeoutId = null; + } + }; if ($generateAISummariesButton.length > 0) { $generateAISummariesButton.on('click', function () { const $button = $(this); const triggerButton = $button.data('trigger-button'); const $activeButton = triggerButton ? $(triggerButton) : $button; - const applicationId = new URL(window.location.toLocaleString()).searchParams.get('ApplicationId'); + const applicationId = new URL(globalThis.location.toLocaleString()).searchParams.get('ApplicationId'); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; $button.removeData('trigger-button'); @@ -169,12 +163,6 @@ $(function () { } const existingHTML = $activeButton.html(); - const stopPolling = function () { - if (aiSummaryPollTimeoutId) { - clearTimeout(aiSummaryPollTimeoutId); - aiSummaryPollTimeoutId = null; - } - }; const poll = function () { unity.grantManager.grantApplications.grantApplication .getAIGenerationStatus(applicationId, 'attachment-summary', promptVersion) @@ -403,6 +391,18 @@ $(function () { }); }); +function getChefsIconColumn() { + return { + title: '', + width: '40px', + className: 'text-center', + render: function () { + return ''; + }, + orderable: false, + }; +} + function getChefsFileDownloadColumn() { return { title: '', From f8a6b7c0bfd15fb8fc9322c52239edd5b5f19780 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 27 Apr 2026 14:41:48 -0700 Subject: [PATCH 26/47] AB#32451 apply Copilot AI queue fixes --- .../RunApplicationAIPipelineJobArgs.cs | 2 +- .../ApplicationAIGenerationQueue.cs | 30 +++++++++++++++++-- .../AIGenerationRequestJobHelper.cs | 5 ++-- .../RunApplicationAIPipelineJob.cs | 7 +++-- .../GrantApplicationAppService.cs | 30 +++++++++++++++++++ .../GrantManagerDbContext.cs | 2 +- .../20260415121500_AddAIRequestTable.cs | 4 +-- .../GrantManagerDbContextModelSnapshot.cs | 2 +- .../Pages/GrantApplications/Details.js | 6 ++-- .../AssessmentScoresWidget/Default.js | 6 ++-- .../Components/ReviewList/ReviewList.js | 6 ++-- 11 files changed, 82 insertions(+), 18 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJobArgs.cs index 418a5dc665..3f5fee5594 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJobArgs.cs @@ -7,5 +7,5 @@ public class RunApplicationAIPipelineJobArgs public Guid ApplicationId { get; set; } public Guid? TenantId { get; set; } public string? PromptVersion { get; set; } - public string? RequestKey { get; set; } + public string RequestKey { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 81e57b11a8..2d787933ab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -5,6 +5,7 @@ using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Medallion.Threading; +using Microsoft.Extensions.Logging; using Volo.Abp.Domain.Repositories; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; @@ -14,7 +15,8 @@ namespace Unity.GrantManager.GrantApplications.Automation; public class ApplicationAIGenerationQueue( IBackgroundJobManager backgroundJobManager, IRepository generationRequestRepository, - IDistributedLockProvider distributedLockProvider) + IDistributedLockProvider distributedLockProvider, + ILogger logger) : IApplicationAIGenerationQueue, ITransientDependency { public async Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null) @@ -131,7 +133,31 @@ private async Task EnsureRequestAndEnqueueAsync( requestKey); await generationRequestRepository.InsertAsync(request, autoSave: true); - await enqueue(); + + try + { + await enqueue(); + } + catch (Exception ex) + { + await MarkFailedBestEffortAsync(request, ex); + throw; + } + } + } + + private async Task MarkFailedBestEffortAsync(AIGenerationRequest request, Exception exception) + { + try + { + await AIGenerationRequestJobHelper.MarkFailedAsync(generationRequestRepository, request, exception.Message); + } + catch (Exception markException) + { + logger.LogError( + markException, + "Failed to mark AI generation request {RequestId} as failed after enqueue failure.", + request.Id); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs index 5e898fea1b..843b091203 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs @@ -53,8 +53,9 @@ public static async Task MarkFailedAsync( IRepository generationRequestRepository, Expression> predicate) { - var requests = await generationRequestRepository.GetListAsync(predicate); - return requests + var query = await generationRequestRepository.GetQueryableAsync(); + return query + .Where(predicate) .OrderByDescending(x => x.CreationTime) .ThenByDescending(x => x.Id) .FirstOrDefault(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs index 427ce252ee..def126a49e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs @@ -29,6 +29,11 @@ public class RunApplicationAIPipelineJob( { public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) { + if (string.IsNullOrWhiteSpace(args.RequestKey)) + { + throw new ArgumentException("RequestKey is required.", nameof(args.RequestKey)); + } + using (currentTenant.Change(args.TenantId)) { var request = await AIGenerationRequestJobHelper.GetLatestRequestAsync( @@ -101,13 +106,11 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent if (scoringException != null) { - await AIGenerationRequestJobHelper.MarkFailedAsync(generationRequestRepository, request, scoringException.Message); throw scoringException; } if (analysisException != null) { - await AIGenerationRequestJobHelper.MarkFailedAsync(generationRequestRepository, request, analysisException.Message); throw analysisException; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index eb3ff058d3..b27fc4c051 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -1041,6 +1041,7 @@ public async Task QueueAIGenerationAsync(Guid applicatio return await QueueApplicationAnalysisAsync(applicationId, promptVersion); } + [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] public async Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { await EnsureAIAnalysisEnabledAsync(); @@ -1054,6 +1055,7 @@ public async Task QueueApplicationAnalysisAsync(Guid app return request ?? throw new UserFriendlyException("Unable to queue AI analysis request."); } + [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] public async Task QueueAttachmentSummaryAsync(Guid applicationId, string? promptVersion = null) { await EnsureAttachmentSummariesEnabledAsync(); @@ -1067,6 +1069,7 @@ public async Task QueueAttachmentSummaryAsync(Guid appli return request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request."); } + [Authorize(AIPermissions.Analysis.GenerateScoring)] public async Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { await EnsureScoringEnabledAsync(); @@ -1082,9 +1085,13 @@ public async Task QueueApplicationScoringAsync(Guid appl public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) { + await EnsureAIGenerationStatusAccessAsync(operationType); return await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id); } + [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] + [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] + [Authorize(AIPermissions.Analysis.GenerateScoring)] public async Task QueueAllAIStagesAsync(Guid applicationId, string? promptVersion = null) { await EnsureAttachmentSummariesEnabledAsync(); @@ -1100,6 +1107,29 @@ public async Task QueueAllAIStagesAsync(Guid application return request ?? throw new UserFriendlyException("Unable to queue AI generation request."); } + private async Task EnsureAIGenerationStatusAccessAsync(string operationType) + { + switch (operationType) + { + case AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType: + await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewApplicationAnalysis); + return; + case AIGenerationRequestKeyHelper.AttachmentSummaryOperationType: + await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewAttachmentSummary); + return; + case AIGenerationRequestKeyHelper.ApplicationScoringOperationType: + await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewScoringResult); + return; + case AIGenerationRequestKeyHelper.PipelineOperationType: + await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewApplicationAnalysis); + await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewAttachmentSummary); + await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewScoringResult); + return; + default: + throw new UserFriendlyException("Unknown AI generation operation type."); + } + } + private async Task EnsureAttachmentSummariesEnabledAsync() { if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 61e8b3b289..8e81c33850 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -247,7 +247,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(x => x.FailureReason).HasMaxLength(2000); b.Property(x => x.Status).IsRequired(); b.HasIndex(x => x.RequestKey); - b.HasIndex(x => new { x.ApplicationId, x.OperationType, x.Status }); + b.HasIndex(x => new { x.TenantId, x.ApplicationId, x.OperationType, x.Status }); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIRequestTable.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIRequestTable.cs index 9539a61e78..f2f4954571 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIRequestTable.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260415121500_AddAIRequestTable.cs @@ -39,10 +39,10 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateIndex( - name: "IX_AIRequests_ApplicationId_OperationType_Status", + name: "IX_AIRequests_TenantId_ApplicationId_OperationType_Status", schema: "AI", table: "AIRequests", - columns: new[] { "ApplicationId", "OperationType", "Status" }); + columns: new[] { "TenantId", "ApplicationId", "OperationType", "Status" }); migrationBuilder.CreateIndex( name: "IX_AIRequests_RequestKey", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index 40ae752ad2..8cb9d112f7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -739,7 +739,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("RequestKey"); - b.HasIndex("ApplicationId", "OperationType", "Status"); + b.HasIndex("TenantId", "ApplicationId", "OperationType", "Status"); b.ToTable("AIRequests", "AI"); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 4519cf9c1a..1e7cabeb86 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -369,9 +369,9 @@ $(function () { const statusText = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (statusText === 'Failed') { stopAIGenerationPolling(); - loadDevAiOutputs(); globalThis.AIGenerationButtonState?.restore(restoreButton); - restoreButton.html('Completed').prop('disabled', true); + restoreButton.html(originalHtml).prop('disabled', false); + loadDevAiOutputs(); abp.message.error(request?.failureReason || 'AI generate all failed.'); return; } @@ -411,7 +411,7 @@ $(function () { .fail(function() { abp.message.error(failureMessage); globalThis.AIGenerationButtonState?.restore(restoreButton); - restoreButton.html('Completed').prop('disabled', true); + restoreButton.html(originalHtml).prop('disabled', false); }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 9ee58297ab..1e7dd26748 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -610,7 +610,7 @@ function queueApplicationScoring(triggerButton = null) { unity.grantManager.grantApplications.grantApplication .getAIGenerationStatus(applicationId, 'application-scoring', promptVersion) .done(function (request) { - const status = request?.status; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Failed') { stopPolling(); @@ -638,7 +638,9 @@ function queueApplicationScoring(triggerButton = null) { unity.grantManager.grantApplications.applicationScoring .queueApplicationScoring(applicationId, promptVersion) .done(function (request) { - if (request?.status === 'Completed') { + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + + if (status === 'Completed') { $button.html('Completed').prop('disabled', true); PubSub.publish('refresh_assessment_scores', null); return; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js index 0f378e12d1..e5368d36ff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js @@ -478,7 +478,7 @@ function generateAiButtonAction(e, dt, button, config) { unity.grantManager.grantApplications.grantApplication .getAIGenerationStatus(pageApplicationId, 'application-scoring', promptVersion) .done(function (request) { - const status = request?.status; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Failed') { stopPolling(); @@ -507,7 +507,9 @@ function generateAiButtonAction(e, dt, button, config) { unity.grantManager.grantApplications.grantApplication.queueApplicationScoring(pageApplicationId, promptVersion) .done(function (request) { - if (request?.status === 'Completed') { + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + + if (status === 'Completed') { setReviewListAiButtonCompleted($button); refreshReviewListAfterAiScoring(); return; From 2f2433d3f8df92d96d9d5c08cfdb357f7c5cda55 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 27 Apr 2026 15:04:47 -0700 Subject: [PATCH 27/47] AB#32451 fix AI permissions namespace import --- .../GrantApplications/GrantApplicationAppService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 6e65ca15a2..34726393ad 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -27,6 +27,7 @@ using Unity.Modules.Shared.Correlation; using Unity.Payments.PaymentRequests; using Unity.AI.Automation; +using Unity.AI.Permissions; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Authorization; From 7e520c7ad309295ddc9fa744f3dcd2162138b451 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 27 Apr 2026 15:54:11 -0700 Subject: [PATCH 28/47] AB#32451 fix AI queue tests for logger and queryable helper --- .../Automation/AIGenerationQueueTests.cs | 26 ++++++++++++++----- .../RunApplicationAIPipelineJobTests.cs | 8 ++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index 888a6e90c2..45dcdf556d 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -1,4 +1,5 @@ using Medallion.Threading; +using Microsoft.Extensions.Logging; using NSubstitute; using Shouldly; using System; @@ -41,7 +42,7 @@ public async Task QueueAllAIStagesAsync_Should_Enqueue_Pipeline_Job_When_None_Ex return Task.FromResult(string.Empty); }); - var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + var queue = CreateQueue(backgroundJobManager, repository); await queue.QueueAllAIStagesAsync(applicationId, tenantId, "v1"); @@ -77,7 +78,7 @@ public async Task QueueApplicationAnalysisAsync_Should_Not_Enqueue_When_An_Activ repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); var backgroundJobManager = Substitute.For(); - var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + var queue = CreateQueue(backgroundJobManager, repository); await queue.QueueApplicationAnalysisAsync(applicationId, tenantId, promptVersion); @@ -108,7 +109,7 @@ public async Task QueueApplicationAnalysisAsync_Should_Enqueue_New_Request_When_ return Task.FromResult(string.Empty); }); - var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + var queue = CreateQueue(backgroundJobManager, repository); await queue.QueueApplicationAnalysisAsync(applicationId, tenantId, promptVersion); @@ -143,7 +144,7 @@ public async Task QueueAttachmentSummaryAsync_Should_Not_Enqueue_When_An_Active_ repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); var backgroundJobManager = Substitute.For(); - var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + var queue = CreateQueue(backgroundJobManager, repository); await queue.QueueAttachmentSummaryAsync(applicationId, tenantId, promptVersion); @@ -174,7 +175,7 @@ public async Task QueueAttachmentSummaryAsync_Should_Enqueue_New_Request_When_No return Task.FromResult(string.Empty); }); - var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + var queue = CreateQueue(backgroundJobManager, repository); await queue.QueueAttachmentSummaryAsync(applicationId, tenantId, promptVersion); @@ -209,7 +210,7 @@ public async Task QueueApplicationScoringAsync_Should_Not_Enqueue_When_An_Active repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); var backgroundJobManager = Substitute.For(); - var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + var queue = CreateQueue(backgroundJobManager, repository); await queue.QueueApplicationScoringAsync(applicationId, tenantId, promptVersion); @@ -240,7 +241,7 @@ public async Task QueueApplicationScoringAsync_Should_Enqueue_New_Request_When_N return Task.FromResult(string.Empty); }); - var queue = new ApplicationAIGenerationQueue(backgroundJobManager, repository, new TestDistributedLockProvider()); + var queue = CreateQueue(backgroundJobManager, repository); await queue.QueueApplicationScoringAsync(applicationId, tenantId, promptVersion); @@ -293,4 +294,15 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } } + + private static ApplicationAIGenerationQueue CreateQueue( + IBackgroundJobManager backgroundJobManager, + IRepository repository) + { + return new ApplicationAIGenerationQueue( + backgroundJobManager, + repository, + new TestDistributedLockProvider(), + Substitute.For>()); + } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs index 2ee20bfebe..9bcde4f0f8 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs @@ -104,12 +104,8 @@ private static IRepository BuildRequestRepository(out requests = requestList; var repository = Substitute.For>(); - repository.GetListAsync(Arg.Any>>()) - .Returns(callInfo => - { - var predicate = callInfo.Arg>>().Compile(); - return Task.FromResult(requestList.Where(predicate).ToList()); - }); + repository.GetQueryableAsync() + .Returns(Task.FromResult>(requestList.AsQueryable())); repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => Task.FromResult(callInfo.Arg())); From 7108393905231cc105ddcaae9add100c420ea3c4 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 27 Apr 2026 15:57:32 -0700 Subject: [PATCH 29/47] AB#32694 copilot feedback --- .../ApplicantTenantMapReconciler.cs | 8 ++-- .../Contacts/ContactManager.cs | 28 +++++++------- .../Components/ApplicantContacts/Default.js | 37 ++++++++++++++----- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs index 8d43fe61ec..0c43076a10 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs @@ -51,9 +51,11 @@ public class ApplicantTenantMapReconciler( foreach (var oidcSub in distinctOidcSubs) { - var subUsername = oidcSub.Contains('@') - ? oidcSub[..oidcSub.IndexOf('@')].ToUpperInvariant() - : oidcSub.ToUpperInvariant(); + var subUsername = SubjectNormalizer.Normalize(oidcSub); + if (subUsername is null) + { + continue; + } desiredMappings.Add((subUsername, tenant.Id, tenant.Name)); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs index 5ab378ac8b..8fb041ecfa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Contacts/ContactManager.cs @@ -67,18 +67,6 @@ public class ContactManager( ArgumentException.ThrowIfNullOrWhiteSpace(entityType); ArgumentNullException.ThrowIfNull(input); - var contact = await contactRepository.GetAsync(contactId); - - contact.Name = input.Name; - contact.Title = input.Title; - contact.Email = input.Email; - contact.HomePhoneNumber = input.HomePhoneNumber; - contact.MobilePhoneNumber = input.MobilePhoneNumber; - contact.WorkPhoneNumber = input.WorkPhoneNumber; - contact.WorkPhoneExtension = input.WorkPhoneExtension; - - await contactRepository.UpdateAsync(contact, autoSave: true); - var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); var links = await AsyncExecuter.ToListAsync(contactLinksQuery .Where(l => l.RelatedEntityType == entityType @@ -91,12 +79,24 @@ public class ContactManager( .WithData("entityType", entityType) .WithData("entityId", entityId); + var contact = await contactRepository.GetAsync(contactId); + + contact.Name = input.Name; + contact.Title = input.Title; + contact.Email = input.Email; + contact.HomePhoneNumber = input.HomePhoneNumber; + contact.MobilePhoneNumber = input.MobilePhoneNumber; + contact.WorkPhoneNumber = input.WorkPhoneNumber; + contact.WorkPhoneExtension = input.WorkPhoneExtension; + + await contactRepository.UpdateAsync(contact); + if (isPrimary) { foreach (var stale in links.Where(l => l.IsPrimary && l.ContactId != contactId)) { stale.IsPrimary = false; - await contactLinkRepository.UpdateAsync(stale, autoSave: true); + await contactLinkRepository.UpdateAsync(stale); } } @@ -113,7 +113,7 @@ public class ContactManager( } if (linkChanged) { - await contactLinkRepository.UpdateAsync(targetLink, autoSave: true); + await contactLinkRepository.UpdateAsync(targetLink); } return (contact, targetLink); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js index c7c5dbacd9..be59456a2f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/Default.js @@ -19,6 +19,18 @@ $(function () { return (template || '').replace('{0}', value); } + function escapeHtml(value) { + return $('
').text(value || '').html(); + } + + function renderEscapedText(value, type) { + if (type !== 'display') { + return value; + } + + return escapeHtml(value); + } + function ensureEditContactModal() { if (editContactModal) { return editContactModal; @@ -64,11 +76,18 @@ $(function () { const appId = pickCaseInsensitive(row, ['applicationId']); const refNo = data || pickCaseInsensitive(row, ['referenceNo']); const hasAppId = !!appId && appId !== '00000000-0000-0000-0000-000000000000'; + const plainTextLabel = refNo || t('nullPlaceholder', '—'); + + if (type !== 'display') { + return plainTextLabel; + } + if (!hasAppId) { - return t('nullPlaceholder', '—'); + return plainTextLabel; } - const label = refNo || t('view', 'View'); - return `${label}`; + + const label = escapeHtml(refNo || t('view', 'View')); + return `${label}`; } function renderPrimaryBadge(row) { // NOSONAR - intentionally scoped here; closure context is needed for widget encapsulation @@ -206,7 +225,7 @@ $(function () { if (type !== 'display') { return name; } - return renderPrimaryBadge(row) + name; + return renderPrimaryBadge(row) + escapeHtml(name); }, targets: 0 }, @@ -214,14 +233,14 @@ $(function () { title: t('columnType', 'Type'), data: 'role', width: '13%', - render: (data) => roleLabelMap[data] || data || t('nullPlaceholder', '—'), + render: (data, type) => renderEscapedText(roleLabelMap[data] || data || t('nullPlaceholder', '—'), type), targets: 1 }, { title: t('columnEmail', 'Email'), data: 'email', width: '22%', - render: (data) => data || t('nullPlaceholder', '—'), + render: (data, type) => renderEscapedText(data || t('nullPlaceholder', '—'), type), targets: 2 }, { @@ -229,8 +248,8 @@ $(function () { data: null, width: '13%', render: (data, type, row) => { - const phone = row.workPhoneNumber || row.mobilePhoneNumber; - return phone || t('nullPlaceholder', '—'); + const phone = row.workPhoneNumber || row.mobilePhoneNumber || t('nullPlaceholder', '—'); + return renderEscapedText(phone, type); }, targets: 3 }, @@ -238,7 +257,7 @@ $(function () { title: t('columnTitle', 'Title'), data: 'title', width: '18%', - render: (data) => data || t('nullPlaceholder', '—'), + render: (data, type) => renderEscapedText(data || t('nullPlaceholder', '—'), type), targets: 4 }, { From d68bb68a7f07d1504db04839711c55c7dfd8799a Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 27 Apr 2026 16:14:33 -0700 Subject: [PATCH 30/47] AB#32451 fix Details.cshtml script section merge --- .../Pages/GrantApplications/Details.cshtml | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index cce1c46858..294997e161 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -60,27 +60,20 @@ && formManualEnabled && await PermissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateScoring); } -@section styles -{ - - -} -<<<<<<< feature/AB#32451-ai-generation-polling-dedupe-and-locking -@section scripts -{ - - - - - -======= -@section scripts -{ - - ->>>>>>> dev - -} +@section styles +{ + + +} +@section scripts +{ + + + + + + +}
@await Component.InvokeAsync("ApplicationBreadcrumbWidget", new { applicationId = @Model.ApplicationId }) From 50249672186f35bfdb0d217dd7c5be898be8ff9c Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 27 Apr 2026 16:20:36 -0700 Subject: [PATCH 31/47] AB#32694 - sonarqube fixes --- .../IApplicantTenantMapReconciler.cs | 1 - .../Applicants/ApplicantAppService.cs | 15 ++++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs index 4d8475bb1e..f398d3de5a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/IApplicantTenantMapReconciler.cs @@ -1,4 +1,3 @@ -using System; using System.Threading.Tasks; namespace Unity.GrantManager.ApplicantProfile; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs index e28d4c961b..b55e659a79 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs @@ -35,7 +35,9 @@ public class ApplicantAppService(IApplicantRepository applicantRepository, IOrgBookService orgBookService, IApplicantAgentRepository applicantAgentRepository, IApplicationRepository applicationRepository) : GrantManagerAppService, IApplicantAppService -{ +{ + private const string ApplicantIdDataKey = "ApplicantId"; + protected new ILogger Logger => LazyServiceProvider.LazyGetService(provider => LoggerFactory?.CreateLogger(GetType().FullName!) ?? NullLogger.Instance); [RemoteService(false)] @@ -276,8 +278,7 @@ private async Task UpdatePrimaryContactAsync(Guid applicantId, UpdatePrimaryCont { case "Contact": await UpdateLinkedContactAsync(applicantId, input); - break; - case "Agent": + break; default: await UpdateAgentContactAsync(applicantId, input); break; @@ -290,7 +291,7 @@ private async Task UpdateAgentContactAsync(Guid applicantId, UpdatePrimaryContac if (applicantAgent.ApplicantId != applicantId) { throw new BusinessException("Unity:Applicant:ContactNotFound") - .WithData("ApplicantId", applicantId) + .WithData(ApplicantIdDataKey, applicantId) .WithData("ContactId", input.Id); } @@ -318,7 +319,7 @@ private async Task UpdateLinkedContactAsync(Guid applicantId, UpdatePrimaryConta if (link == null) { throw new BusinessException("Unity:Applicant:ContactNotFound") - .WithData("ApplicantId", applicantId) + .WithData(ApplicantIdDataKey, applicantId) .WithData("ContactId", input.Id); } @@ -345,14 +346,14 @@ private async Task UpdatePrimaryAddressAsync(Guid applicantId, UpdatePrimaryAppl if (applicantAddress.ApplicantId != applicantId) { throw new BusinessException("Unity:Applicant:AddressNotFound") - .WithData("ApplicantId", applicantId) + .WithData(ApplicantIdDataKey, applicantId) .WithData("AddressId", input.Id); } if (applicantAddress.AddressType != expectedType) { throw new BusinessException("Unity:Applicant:AddressTypeMismatch") - .WithData("ApplicantId", applicantId) + .WithData(ApplicantIdDataKey, applicantId) .WithData("AddressId", input.Id) .WithData("ExpectedType", expectedType.ToString()); } From a651b8b826380429c600fc667d63e7b93efc4139 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Mon, 27 Apr 2026 17:20:58 -0700 Subject: [PATCH 32/47] feature/AB#32632-WorksheetMerge-FixdragDropquestionInSections Co-authored-by: Copilot --- .../Worksheets/ICustomFieldAppService.cs | 1 + .../Domain/Worksheets/CustomField.cs | 2 +- .../Worksheets/CustomFieldAppService.cs | 27 ++++++++++++ .../WorksheetListWidget/WorksheetList.js | 44 ++++++++++++++----- .../Pages/ConfigurationManagement/Index.js | 16 ++++--- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/ICustomFieldAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/ICustomFieldAppService.cs index 0715d4458c..81955ba310 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/ICustomFieldAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/ICustomFieldAppService.cs @@ -8,5 +8,6 @@ public interface ICustomFieldAppService Task GetAsync(Guid id); Task EditAsync(Guid id, EditCustomFieldDto dto); Task DeleteAsync(Guid id); + Task MoveToSectionAsync(Guid fieldId, Guid targetSectionId, uint newIndex); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Worksheets/CustomField.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Worksheets/CustomField.cs index 1eca950ceb..aebf75ba85 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Worksheets/CustomField.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Worksheets/CustomField.cs @@ -32,7 +32,7 @@ public virtual WorksheetSection Section get => _section ?? throw new InvalidOperationException("Uninitialized property: " + nameof(Section)); } - public virtual Guid SectionId { get; } + public virtual Guid SectionId { get; internal set; } private WorksheetSection? _section; protected CustomField() diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldAppService.cs index cb8ba2e617..8b9c696cca 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldAppService.cs @@ -5,6 +5,7 @@ using Volo.Abp.Domain.Entities; using System.Linq; using Unity.Flex.Worksheets.Definitions; +using Volo.Abp; namespace Unity.Flex.Worksheets { @@ -39,5 +40,31 @@ public async Task DeleteAsync(Guid id) section.RemoveField(field); } + + public async Task MoveToSectionAsync(Guid fieldId, Guid targetSectionId, uint newIndex) + { + var field = await customFieldRepostitory.GetAsync(fieldId) ?? throw new EntityNotFoundException(); + if (field.SectionId == targetSectionId) return; + + var worksheet = await worksheetRepository.GetBySectionAsync(field.SectionId, true) ?? throw new EntityNotFoundException(); + if (worksheet.Published) throw new UserFriendlyException("Cannot move fields in a published worksheet."); + + var sourceSection = worksheet.Sections.FirstOrDefault(s => s.Id == field.SectionId) ?? throw new EntityNotFoundException(); + var targetSection = worksheet.Sections.FirstOrDefault(s => s.Id == targetSectionId) ?? throw new EntityNotFoundException(); + + // Renumber source section, excluding the moving field + var sourceFields = sourceSection.Fields.Where(f => f.Id != fieldId).OrderBy(f => f.Order).ToList(); + for (int i = 0; i < sourceFields.Count; i++) + sourceFields[i].SetOrder((uint)(i + 1)); + + // Make room in target section at the insertion point + uint insertAt = Math.Min(newIndex + 1, (uint)(targetSection.Fields.Count + 1)); + foreach (var f in targetSection.Fields.Where(f => f.Order >= insertAt)) + f.SetOrder(f.Order + 1); + + // Update FK directly — EF Core change tracker issues a single UPDATE, avoids orphan-deletion + field.SetOrder(insertAt); + field.SectionId = targetSectionId; + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetListWidget/WorksheetList.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetListWidget/WorksheetList.js index 1f4a10c4e7..c92a195602 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetListWidget/WorksheetList.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetListWidget/WorksheetList.js @@ -104,7 +104,9 @@ function makeCustomFieldsSortable() { document.querySelectorAll('.custom-fields-wrapper').forEach(function (div) { const wrapper = div.closest('.sections-wrapper-outer'); const isArchived = wrapper?.dataset.isArchived === 'true'; + const worksheetId = wrapper?.dataset.worksheetId; customFieldSortables.push(new Sortable(div, { + group: `custom-fields-${worksheetId}`, animation: 150, disabled: isArchived, onEnd: function (evt) { @@ -138,18 +140,36 @@ function makeSectionsSortable() { } function updateCustomFieldsSequence(evt) { - let sectionId = evt.target.dataset.sectionId; - let oldIndex = evt.oldIndex; - let newIndex = evt.newIndex; - - unity.flex.worksheets.worksheetSection - .resequenceCustomFields(sectionId, oldIndex, newIndex, {}) - .done(function () { - updatePreview(); - abp.notify.success( - 'Custom fields order updated.' - ); - }); + if (evt.from === evt.to) { + // Reorder within the same section + const sectionId = evt.from.dataset.sectionId; + const oldIndex = evt.oldIndex; + const newIndex = evt.newIndex; + + unity.flex.worksheets.worksheetSection + .resequenceCustomFields(sectionId, oldIndex, newIndex, {}) + .done(function () { + updatePreview(); + abp.notify.success('Custom fields order updated.'); + }); + } else { + // Move to a different section + const fieldId = evt.item.dataset.id; + const targetSectionId = evt.to.dataset.sectionId; + const newIndex = evt.newIndex; + + unity.flex.worksheets.customField + .moveToSection(fieldId, targetSectionId, newIndex, {}) + .done(function () { + updatePreview(); + abp.notify.success('Field moved to new section.'); + }) + .fail(function () { + // Revert the DOM move on failure + evt.from.insertBefore(evt.item, evt.from.children[evt.oldIndex] || null); + abp.notify.error('Failed to move field.'); + }); + } } function updateSectionSequence(evt) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.js index 44624dc76a..f17597dc1a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.js @@ -1,6 +1,7 @@ $(function () { const menuItems = $('#ConfigurationManagementSideMenu .nav-item'); const configSections = $('.config-section'); + const ACTIVE_MENU_KEY = 'ConfigurationManagement_ActiveMenu'; init(); @@ -12,11 +13,13 @@ adjustDataTables(); }); - // Auto-select the first visible menu item - const firstMenuItem = menuItems.first(); - if (firstMenuItem.length) { - firstMenuItem.addClass('active'); - const targetId = firstMenuItem.data('target'); + // Restore the last active menu item from localStorage, fallback to first + const savedMenuId = localStorage.getItem(ACTIVE_MENU_KEY); + const savedMenuItem = savedMenuId ? menuItems.filter('#' + savedMenuId) : $(); + const activeMenuItem = (savedMenuItem.length ? savedMenuItem : menuItems.first()); + if (activeMenuItem.length) { + activeMenuItem.addClass('active'); + const targetId = activeMenuItem.data('target'); $('#' + targetId).removeClass('hide'); } @@ -39,6 +42,9 @@ const clickedItem = $(e.currentTarget); const targetId = clickedItem.data('target'); + // Persist selection + localStorage.setItem(ACTIVE_MENU_KEY, clickedItem.attr('id')); + // Update active menu item menuItems.removeClass('active'); clickedItem.addClass('active'); From 1722df2de5afdd04b169c884ed951cceb0746581 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Mon, 27 Apr 2026 20:49:07 -0700 Subject: [PATCH 33/47] AB#31305: Supplier Handling on Applicant Merge - Initial Draft --- .../Applicants/ApplicantListDto.cs | 3 + .../Applicants/ApplicantAppService.cs | 83 ++++++++++++------- .../Applicants/ApplicantSupplierAppService.cs | 53 ++++++++++++ .../Applicants/HandleSupplierAfterMergeDto.cs | 10 +++ .../IApplicantSupplierAppService.cs | 15 ++++ .../Repositories/ApplicantRepository.cs | 40 +++++++-- .../Components/ApplicantInfo/Default.cshtml | 17 ++++ .../Components/ApplicantInfo/Default.js | 60 +++++++++++++- .../ApplicantsActionBar/ListMerge.cshtml | 2 + .../ApplicantsActionBar/ListMerge.js | 54 ++++++++++-- 10 files changed, 290 insertions(+), 47 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/HandleSupplierAfterMergeDto.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs index ee8b71bd15..117abbfcdb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs @@ -29,6 +29,9 @@ public class ApplicantListDto : AuditedEntityDto public int? FiscalDay { get; set; } public DateTime? StartedOperatingDate { get; set; } public string? SupplierId { get; set; } + public string? SupplierNumber { get; set; } + public string? SupplierName { get; set; } + public string? SupplierStatus { get; set; } public decimal? MatchPercentage { get; set; } public bool IsDuplicated { get; set; } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs index 0cdd0897f1..f42e42418a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs @@ -22,6 +22,7 @@ using Unity.Payments.Suppliers; using Volo.Abp.DependencyInjection; using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Repositories; namespace Unity.GrantManager.Applicants; @@ -34,7 +35,8 @@ public class ApplicantAppService(IApplicantRepository applicantRepository, IApplicantAddressRepository addressRepository, IOrgBookService orgBookService, IApplicantAgentRepository applicantAgentRepository, - IApplicationRepository applicationRepository) : GrantManagerAppService, IApplicantAppService + IApplicationRepository applicationRepository, + IRepository supplierRepository) : GrantManagerAppService, IApplicantAppService { protected new ILogger Logger => LazyServiceProvider.LazyGetService(provider => LoggerFactory?.CreateLogger(GetType().FullName!) ?? NullLogger.Instance); @@ -678,37 +680,58 @@ public async Task> GetListAsync(ApplicantListRe // Execute query var applicants = await query.ToListAsync(); + // Batch-load supplier data for all applicants in one query + var supplierIds = applicants + .Where(a => a.SupplierId.HasValue) + .Select(a => a.SupplierId!.Value) + .Distinct() + .ToList(); + + Dictionary supplierMap = new(); + if (supplierIds.Count > 0) + { + var suppliers = await supplierRepository.GetListAsync(s => supplierIds.Contains(s.Id)); + supplierMap = suppliers.ToDictionary(s => s.Id); + } + // Map to DTOs - var items = applicants.Select(applicant => new ApplicantListDto + var items = applicants.Select(applicant => { - Id = applicant.Id, - ApplicantName = applicant.ApplicantName, - UnityApplicantId = applicant.UnityApplicantId, - OrgName = applicant.OrgName, - OrgNumber = applicant.OrgNumber, - OrgStatus = applicant.OrgStatus, - OrganizationType = applicant.OrganizationType, - Status = applicant.Status, - RedStop = applicant.RedStop, - NonRegisteredBusinessName = applicant.NonRegisteredBusinessName, - NonRegOrgName = applicant.NonRegOrgName, - OrganizationSize = applicant.OrganizationSize, - Sector = applicant.Sector, - SubSector = applicant.SubSector, - ApproxNumberOfEmployees = applicant.ApproxNumberOfEmployees, - IndigenousOrgInd = applicant.IndigenousOrgInd, - SectorSubSectorIndustryDesc = applicant.SectorSubSectorIndustryDesc, - FiscalMonth = applicant.FiscalMonth, - BusinessNumber = applicant.BusinessNumber, - FiscalDay = applicant.FiscalDay, - StartedOperatingDate = applicant.StartedOperatingDate.HasValue - ? applicant.StartedOperatingDate.Value.ToDateTime(TimeOnly.MinValue) - : null, - SupplierId = applicant.SupplierId?.ToString(), - MatchPercentage = applicant.MatchPercentage, - IsDuplicated = applicant.IsDuplicated, - CreationTime = applicant.CreationTime, - LastModificationTime = applicant.LastModificationTime + supplierMap.TryGetValue(applicant.SupplierId ?? Guid.Empty, out var supplier); + return new ApplicantListDto + { + Id = applicant.Id, + ApplicantName = applicant.ApplicantName, + UnityApplicantId = applicant.UnityApplicantId, + OrgName = applicant.OrgName, + OrgNumber = applicant.OrgNumber, + OrgStatus = applicant.OrgStatus, + OrganizationType = applicant.OrganizationType, + Status = applicant.Status, + RedStop = applicant.RedStop, + NonRegisteredBusinessName = applicant.NonRegisteredBusinessName, + NonRegOrgName = applicant.NonRegOrgName, + OrganizationSize = applicant.OrganizationSize, + Sector = applicant.Sector, + SubSector = applicant.SubSector, + ApproxNumberOfEmployees = applicant.ApproxNumberOfEmployees, + IndigenousOrgInd = applicant.IndigenousOrgInd, + SectorSubSectorIndustryDesc = applicant.SectorSubSectorIndustryDesc, + FiscalMonth = applicant.FiscalMonth, + BusinessNumber = applicant.BusinessNumber, + FiscalDay = applicant.FiscalDay, + StartedOperatingDate = applicant.StartedOperatingDate.HasValue + ? applicant.StartedOperatingDate.Value.ToDateTime(TimeOnly.MinValue) + : null, + SupplierId = applicant.SupplierId?.ToString(), + SupplierNumber = supplier?.Number, + SupplierName = supplier?.Name, + SupplierStatus = supplier?.Status, + MatchPercentage = applicant.MatchPercentage, + IsDuplicated = applicant.IsDuplicated, + CreationTime = applicant.CreationTime, + LastModificationTime = applicant.LastModificationTime + }; }).ToList(); return new PagedResultDto(totalCount, items); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs index 1a7ee7bb1c..b49bfd1371 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs @@ -148,4 +148,57 @@ public async Task EnsureNoPendingPaymentsForApplicantAsync(Guid applicantId) if (!applicant.SupplierId.HasValue) return null; return await supplierAppService.GetAsync(applicant.SupplierId.Value); } + + [HttpGet("api/app/applicant-supplier/has-pending-payments-for-merge")] + public async Task HasPendingPaymentsForMergeAsync(Guid principalId, Guid nonPrincipalId) + { + if (!await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature)) + { + return false; + } + + var principalAppIds = (await applicationRepository + .GetListAsync(a => a.ApplicantId == principalId)) + .Select(a => a.Id) + .ToList(); + + var nonPrincipalAppIds = (await applicationRepository + .GetListAsync(a => a.ApplicantId == nonPrincipalId)) + .Select(a => a.Id) + .ToList(); + + var allAppIds = principalAppIds.Concat(nonPrincipalAppIds).ToList(); + if (allAppIds.Count == 0) return false; + + var pendingPayments = await paymentRequestService + .GetPaymentPendingListByCorrelationIdsAsync(allAppIds); + + return pendingPayments != null && pendingPayments.Count > 0; + } + + [HttpPost("api/app/applicant-supplier/handle-supplier-after-merge")] + [Authorize(UnitySelector.Payment.Supplier.Update)] + public async Task HandleSupplierAfterMergeAsync(HandleSupplierAfterMergeDto dto) + { + await EnsureNoPendingPaymentsForApplicantAsync(dto.PrincipalId); + await EnsureNoPendingPaymentsForApplicantAsync(dto.NonPrincipalId); + + var principal = await applicantRepository.GetAsync(dto.PrincipalId); + principal.SupplierId = dto.SelectedSupplierId; + await applicantRepository.UpdateAsync(principal); + + var nonPrincipal = await applicantRepository.GetAsync(dto.NonPrincipalId); + nonPrincipal.SupplierId = dto.SelectedSupplierId; + await applicantRepository.UpdateAsync(nonPrincipal); + + // Null DefaultSiteId on all applications now belonging to the principal + // (both transferred and pre-existing). Staff re-set per application. + var applications = await applicationRepository + .GetListAsync(a => a.ApplicantId == dto.PrincipalId); + foreach (var application in applications) + { + application.DefaultSiteId = null; + await applicationRepository.UpdateAsync(application); + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/HandleSupplierAfterMergeDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/HandleSupplierAfterMergeDto.cs new file mode 100644 index 0000000000..58f2f6ba97 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/HandleSupplierAfterMergeDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Unity.GrantManager.Applicants; + +public class HandleSupplierAfterMergeDto +{ + public Guid PrincipalId { get; set; } + public Guid NonPrincipalId { get; set; } + public Guid? SelectedSupplierId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantSupplierAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantSupplierAppService.cs index 5b144cdf88..992a6bae87 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantSupplierAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantSupplierAppService.cs @@ -16,4 +16,19 @@ public interface IApplicantSupplierAppService : IApplicationService Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string supplierNumber, Guid? applicationId = null); Task UpdateAplicantSupplierByBn9Async(Guid applicantId, string bn9); Task EnsureNoPendingPaymentsForApplicantAsync(Guid applicantId); + + /// + /// Returns true if either the principal or non-principal applicant has any + /// in-progress payments (L1Pending / L2Pending / L3Pending). Used by the + /// Merge UI to show a warning before the merge is executed. + /// + Task HasPendingPaymentsForMergeAsync(Guid principalId, Guid nonPrincipalId); + + /// + /// Applies supplier and DefaultSiteId changes after a merge: + /// sets SupplierId = selectedSupplierId on both the principal and non-principal + /// (keeping the duplicate record in sync), and nulls DefaultSiteId on every + /// application that now belongs to the principal. + /// + Task HandleSupplierAfterMergeAsync(HandleSupplierAfterMergeDto dto); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs index 886f68310a..102136373c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Unity.GrantManager.Applications; using Unity.GrantManager.EntityFrameworkCore; +using Unity.Payments.Domain.Suppliers; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore; @@ -73,12 +74,36 @@ public async Task GetApplicantAutocompleteQueryAsync(string? appli .AsNoTracking() .ToListAsync(); - var filteredApplicants = applicants + var filtered = applicants .Where(a => (!string.IsNullOrEmpty(a.ApplicantName) && a.ApplicantName.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(a.UnityApplicantId) && a.UnityApplicantId.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)) ) - .Select(a => new + .Take(10) + .ToList(); + + // Batch-fetch supplier data for the matched applicants + var supplierIds = filtered + .Where(a => a.SupplierId.HasValue) + .Select(a => a.SupplierId!.Value) + .Distinct() + .ToList(); + + Dictionary supplierMap = new(); + if (supplierIds.Count > 0) + { + var suppliers = await dbContext.Set() + .AsNoTracking() + .Where(s => supplierIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Number, s.Name, s.Status }) + .ToListAsync(); + supplierMap = suppliers.ToDictionary(s => s.Id, s => (s.Number, s.Name, s.Status)); + } + + var filteredApplicants = filtered.Select(a => + { + supplierMap.TryGetValue(a.SupplierId ?? Guid.Empty, out var sup); + return new { a.Id, a.ApplicantName, @@ -97,10 +122,13 @@ public async Task GetApplicantAutocompleteQueryAsync(string? appli a.FiscalDay, a.FiscalMonth, a.UnityApplicantId, - a.IsDuplicated - }) - .Take(10) - .ToList(); + a.IsDuplicated, + SupplierId = a.SupplierId?.ToString(), + SupplierNumber = sup.Number, + SupplierName = sup.Name, + SupplierStatus = sup.Status + }; + }); var json = JsonSerializer.Serialize(filteredApplicants); return JsonDocument.Parse(json); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml index 960f806cc0..2560aed15c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml @@ -629,6 +629,21 @@ + + Supplier + + + + + + +
@@ -646,6 +661,8 @@ The other record will not be deleted; instead, it will be flagged as a duplicate and can be removed from the Applicant list in a separate process.

Note: The address and contact information for any affected applications will be preserved and remain untouched.

+

Note: All default sites associated with submissions linked to the merged applicant will be removed and must be re‑set after the merge is completed.

+
Warning: One or more affected submissions have in‑progress payments. These payments will be impacted by the merge. Please complete final approval of all payments before proceeding.

Are you sure?