From 63901e7cf1c42f7c789f410d3c34be79ff935fc6 Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 7 May 2026 13:42:05 -0700 Subject: [PATCH 01/21] Column index generation created across main tables to support the GrantApplications table query optimization Recreated indexes post .NET10 upgrade from previous branch to properly capture migration changes --- .../GrantTenantDbContext.cs | 14 +- ...plications_Performance_Indexes.Designer.cs | 5031 ++++++++++++++++ ...03_Add_Applications_Performance_Indexes.cs | 73 + ...porting_Table_TenantId_Indexes.Designer.cs | 5038 +++++++++++++++++ ...9_Add_Supporting_Table_TenantId_Indexes.cs | 46 + .../GrantTenantDbContextModelSnapshot.cs | 20 + 6 files changed, 10219 insertions(+), 3 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs index 0d70b15a5..166e8cf1f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -68,6 +68,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) GrantManagerConsts.DbSchema); b.ConfigureByConvention(); b.HasIndex(x => x.OidcSub); + b.HasIndex(x => x.TenantId); }); modelBuilder.Entity(b => @@ -80,6 +81,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasMaxLength(600); b.HasIndex(x => x.ApplicantName); + b.HasIndex(x => x.TenantId); b.HasMany() .WithOne(s => s.Applicant) @@ -111,6 +113,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(x => x.ParentFormId) .IsRequired(false) .OnDelete(DeleteBehavior.NoAction); + + b.HasIndex(x => new { x.TenantId, x.IsDeleted }).HasFilter("\"IsDeleted\" = false"); }); modelBuilder.Entity(b => @@ -156,6 +160,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(s => s.Applications) .HasForeignKey(x => x.ApplicationStatusId) .IsRequired(); + + b.HasIndex(x => new { x.TenantId, x.SubmissionDate }).HasFilter("\"IsDeleted\" = false"); + b.HasIndex(x => x.ReferenceNo); }); modelBuilder.Entity(b => @@ -181,6 +188,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.ConfigureByConvention(); //auto configure for the base class props b.HasOne().WithMany().HasForeignKey(x => x.ApplicantId).IsRequired(); + b.HasIndex(x => new { x.TenantId, x.ApplicationId }); }); modelBuilder.Entity(b => @@ -286,6 +294,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) GrantManagerConsts.DbSchema); b.ConfigureByConvention(); + b.HasIndex(x => new { x.TenantId, x.ApplicationId }); }); modelBuilder.Entity(b => @@ -298,8 +307,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(x => x.TagId) .IsRequired() .OnDelete(DeleteBehavior.NoAction); - - + b.HasIndex(x => new { x.TenantId, x.ApplicationId }); }); modelBuilder.Entity(b => @@ -327,7 +335,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .IsRequired() .HasDefaultValue(ApplicationLinkType.Related) .HasConversion(new EnumToStringConverter()); - + b.HasIndex(x => new { x.TenantId, x.ApplicationId }); }); modelBuilder.Entity(b => diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.Designer.cs new file mode 100644 index 000000000..bbd75153b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.Designer.cs @@ -0,0 +1,5031 @@ +// +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.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260507201003_Add_Applications_Performance_Indexes")] + partial class Add_Applications_Performance_Indexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationSize") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("ReportsComments") + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ApplicantAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DefaultSiteId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AutomaticallyGenerateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ManuallyInitiateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("RenderFormIoToHtml") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("RenderedHTML") + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("text"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OneTimeConsideration") + .HasColumnType("numeric"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalYear") + .HasColumnType("text"); + + b.Property("IncompleteReport") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Outstanding") + .HasColumnType("boolean"); + + b.Property("ReportDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ReportsHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicantComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.cs new file mode 100644 index 000000000..316d886af --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201003_Add_Applications_Performance_Indexes.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class Add_Applications_Performance_Indexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_ApplicationTags_TenantId_ApplicationId", + table: "ApplicationTags", + columns: new[] { "TenantId", "ApplicationId" }); + + migrationBuilder.CreateIndex( + name: "IX_Applications_ReferenceNo", + table: "Applications", + column: "ReferenceNo"); + + migrationBuilder.CreateIndex( + name: "IX_Applications_TenantId_SubmissionDate", + table: "Applications", + columns: new[] { "TenantId", "SubmissionDate" }, + filter: "\"IsDeleted\" = false"); + + migrationBuilder.CreateIndex( + name: "IX_ApplicationLinks_TenantId_ApplicationId", + table: "ApplicationLinks", + columns: new[] { "TenantId", "ApplicationId" }); + + migrationBuilder.CreateIndex( + name: "IX_ApplicationAssignments_TenantId_ApplicationId", + table: "ApplicationAssignments", + columns: new[] { "TenantId", "ApplicationId" }); + + migrationBuilder.CreateIndex( + name: "IX_ApplicantAgents_TenantId_ApplicationId", + table: "ApplicantAgents", + columns: new[] { "TenantId", "ApplicationId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ApplicationTags_TenantId_ApplicationId", + table: "ApplicationTags"); + + migrationBuilder.DropIndex( + name: "IX_Applications_ReferenceNo", + table: "Applications"); + + migrationBuilder.DropIndex( + name: "IX_Applications_TenantId_SubmissionDate", + table: "Applications"); + + migrationBuilder.DropIndex( + name: "IX_ApplicationLinks_TenantId_ApplicationId", + table: "ApplicationLinks"); + + migrationBuilder.DropIndex( + name: "IX_ApplicationAssignments_TenantId_ApplicationId", + table: "ApplicationAssignments"); + + migrationBuilder.DropIndex( + name: "IX_ApplicantAgents_TenantId_ApplicationId", + table: "ApplicantAgents"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.Designer.cs new file mode 100644 index 000000000..663a51956 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.Designer.cs @@ -0,0 +1,5038 @@ +// +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.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260507201139_Add_Supporting_Table_TenantId_Indexes")] + partial class Add_Supporting_Table_TenantId_Indexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationSize") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("ReportsComments") + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.HasIndex("TenantId"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ApplicantAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DefaultSiteId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AutomaticallyGenerateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ManuallyInitiateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("RenderFormIoToHtml") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.HasIndex("TenantId", "IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("RenderedHTML") + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("text"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OneTimeConsideration") + .HasColumnType("numeric"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalYear") + .HasColumnType("text"); + + b.Property("IncompleteReport") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Outstanding") + .HasColumnType("boolean"); + + b.Property("ReportDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ReportsHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicantComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.HasIndex("TenantId"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.cs new file mode 100644 index 000000000..4cfd16f94 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260507201139_Add_Supporting_Table_TenantId_Indexes.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class Add_Supporting_Table_TenantId_Indexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Persons_TenantId", + table: "Persons", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_ApplicationForms_TenantId_IsDeleted", + table: "ApplicationForms", + columns: new[] { "TenantId", "IsDeleted" }, + filter: "\"IsDeleted\" = false"); + + migrationBuilder.CreateIndex( + name: "IX_Applicants_TenantId", + table: "Applicants", + column: "TenantId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Persons_TenantId", + table: "Persons"); + + migrationBuilder.DropIndex( + name: "IX_ApplicationForms_TenantId_IsDeleted", + table: "ApplicationForms"); + + migrationBuilder.DropIndex( + name: "IX_Applicants_TenantId", + table: "Applicants"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs index 1722b5648..cff23c144 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs @@ -915,6 +915,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ApplicantName"); + b.HasIndex("TenantId"); + b.ToTable("Applicants", (string)null); }); @@ -1103,6 +1105,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ApplicationId") .IsUnique(); + b.HasIndex("TenantId", "ApplicationId"); + b.ToTable("ApplicantAgents", (string)null); }); @@ -1393,6 +1397,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OwnerId"); + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + b.ToTable("Applications", (string)null); }); @@ -1448,6 +1457,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("AssigneeId"); + b.HasIndex("TenantId", "ApplicationId"); + b.ToTable("ApplicationAssignments", (string)null); }); @@ -1783,6 +1794,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ParentFormId"); + b.HasIndex("TenantId", "IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + b.ToTable("ApplicationForms", (string)null); }); @@ -1999,6 +2013,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ApplicationId"); + b.HasIndex("TenantId", "ApplicationId"); + b.ToTable("ApplicationLinks", (string)null); }); @@ -2109,6 +2125,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TagId"); + b.HasIndex("TenantId", "ApplicationId"); + b.ToTable("ApplicationTags", (string)null); }); @@ -2926,6 +2944,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OidcSub"); + b.HasIndex("TenantId"); + b.ToTable("Persons", (string)null); }); From 9fb60537ece9a2182e359cf18cc185127d33d987 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 30 Apr 2026 15:46:15 -0700 Subject: [PATCH 02/21] AB#32290 throttle AI analysis with per-user 60s cooldown Adds an IAIRateLimiter (per-user IDistributedCache key "AI:Cooldown:{userId}" with 60s TTL configurable via "AI:RateLimit:CooldownSeconds") and wires it into the single AI chokepoint, ApplicationAIGenerationQueue. All four manual generate flows (Application Analysis, Application Scoring, Attachment Summaries, Generate All) and any future caller now go through the same gate. Background/system callers without an authenticated user are not rate-limited. Adds AIRateLimitAppService exposing GET /api/app/ai-rate-limit/state and ai-rate-limit.js, which on Details page load fetches the current user's remaining cooldown, disables every .ai-generate-btn with a "Wait Ns" countdown, and re-fetches after each click so the cooldown persists across page refreshes and sibling buttons. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RateLimit/AIRateLimitStateDto.cs | 6 + .../RateLimit/IAIRateLimitAppService.cs | 9 ++ .../RateLimit/IAIRateLimiter.cs | 18 +++ .../RateLimit/AIRateLimitAppService.cs | 11 ++ .../RateLimit/AIRateLimiter.cs | 85 ++++++++++++++ .../ApplicationAIGenerationQueue.cs | 6 + .../Pages/GrantApplications/Details.cshtml | 25 ++-- .../Pages/GrantApplications/ai-rate-limit.js | 109 ++++++++++++++++++ .../AI/RateLimit/AIRateLimiterTests.cs | 104 +++++++++++++++++ .../Automation/AIGenerationQueueTests.cs | 3 + 10 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimitAppService.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimitAppService.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs new file mode 100644 index 000000000..9f53b07bb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.RateLimit; + +public class AIRateLimitStateDto +{ + public int RetryAfterSeconds { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimitAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimitAppService.cs new file mode 100644 index 000000000..cb117a877 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimitAppService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.AI.RateLimit; + +public interface IAIRateLimitAppService : IApplicationService +{ + Task GetStateAsync(); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs new file mode 100644 index 000000000..0755ab0c2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace Unity.AI.RateLimit; + +public interface IAIRateLimiter +{ + /// + /// Throws if the current user is still + /// inside their AI generate cooldown window, otherwise stamps a fresh cooldown. + /// + Task EnsureAndStampAsync(); + + /// + /// Returns the remaining cooldown for the current user. RetryAfterSeconds is 0 when + /// the user can generate immediately. + /// + Task GetStateAsync(); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimitAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimitAppService.cs new file mode 100644 index 000000000..a8d018a36 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimitAppService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Unity.AI.RateLimit; + +[Authorize] +public class AIRateLimitAppService(IAIRateLimiter rateLimiter) + : AIAppService, IAIRateLimitAppService +{ + public virtual Task GetStateAsync() => rateLimiter.GetStateAsync(); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs new file mode 100644 index 000000000..24a4b22e8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -0,0 +1,85 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Users; + +namespace Unity.AI.RateLimit; + +/// +/// Per-user cooldown for AI generate calls. KISS: a single cache entry per user +/// holds the cooldown end ticks; the cache TTL matches the cooldown so a missing +/// entry means the user can generate again. Anonymous/system callers are not +/// rate-limited (background event handlers also flow through the AI queue). +/// +public class AIRateLimiter( + IDistributedCache cache, + ICurrentUser currentUser, + IConfiguration configuration) : IAIRateLimiter, ITransientDependency +{ + private const string KeyPrefix = "AI:Cooldown:"; + private const int DefaultCooldownSeconds = 60; + + private int CooldownSeconds => + configuration.GetValue("AI:RateLimit:CooldownSeconds") ?? DefaultCooldownSeconds; + + public virtual async Task EnsureAndStampAsync() + { + if (currentUser.Id is not Guid userId) + { + // No user (background/system flow). User-level rate limit does not apply. + return; + } + + var remaining = await GetRemainingSecondsAsync(userId); + if (remaining > 0) + { + throw new UserFriendlyException( + $"AI generation is rate limited. Try again in {remaining} second{(remaining == 1 ? "" : "s")}."); + } + + await StampAsync(userId, CooldownSeconds); + } + + public virtual async Task GetStateAsync() + { + if (currentUser.Id is not Guid userId) + { + return new AIRateLimitStateDto { RetryAfterSeconds = 0 }; + } + + return new AIRateLimitStateDto + { + RetryAfterSeconds = await GetRemainingSecondsAsync(userId) + }; + } + + private async Task GetRemainingSecondsAsync(Guid userId) + { + var raw = await cache.GetStringAsync(KeyFor(userId)); + if (string.IsNullOrEmpty(raw) || + !long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var untilTicks) || + untilTicks < DateTime.MinValue.Ticks || + untilTicks > DateTime.MaxValue.Ticks) + { + return 0; + } + + var seconds = (int)Math.Ceiling((new DateTime(untilTicks, DateTimeKind.Utc) - DateTime.UtcNow).TotalSeconds); + return seconds > 0 ? seconds : 0; + } + + private async Task StampAsync(Guid userId, int seconds) + { + var until = DateTime.UtcNow.AddSeconds(seconds); + await cache.SetStringAsync( + KeyFor(userId), + until.Ticks.ToString(CultureInfo.InvariantCulture), + new DistributedCacheEntryOptions { AbsoluteExpiration = until }); + } + + private static string KeyFor(Guid userId) => KeyPrefix + userId; +} 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 2d787933a..ea3e6d678 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 @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Unity.AI.Automation; +using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Medallion.Threading; @@ -16,6 +17,7 @@ public class ApplicationAIGenerationQueue( IBackgroundJobManager backgroundJobManager, IRepository generationRequestRepository, IDistributedLockProvider distributedLockProvider, + IAIRateLimiter aiRateLimiter, ILogger logger) : IApplicationAIGenerationQueue, ITransientDependency { @@ -106,6 +108,10 @@ private async Task EnsureRequestAndEnqueueAsync( Guid? applicationId, Func enqueue) { + // Single chokepoint for all AI generate flows (manual + auto). + // The limiter is a no-op for system/background callers without an authenticated user. + await aiRateLimiter.EnsureAndStampAsync(); + var requestLock = distributedLockProvider.CreateLock($"ai-generation:{requestKey}"); using (await requestLock.AcquireAsync()) 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 1b2cd15d4..13133fe39 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 @@ -50,18 +50,19 @@ && formManualEnabled && await PermissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateApplicationAnalysis); } -@section styles -{ - - -} -@section scripts -{ - - - - -} +@section styles +{ + + +} +@section scripts +{ + + + + + +}
@await Component.InvokeAsync("ApplicationBreadcrumbWidget", new { applicationId = @Model.ApplicationId }) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js new file mode 100644 index 000000000..e43fc5fb1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js @@ -0,0 +1,109 @@ +/* AB#32300 — per-user 60s cooldown for AI Generate buttons. + * Server stamps the cooldown; this module only mirrors that state in the UI. + * Strategy: on load, fetch the user's remaining seconds and disable every + * .ai-generate-btn with a countdown. After any generate click resolves we + * re-fetch (a successful click sets a new cooldown; a failed/blocked click + * may report the existing one). KISS — no per-button logic, no mutex. + */ +(function () { + const BUTTON_SELECTOR = '.ai-generate-btn'; + const ATTR_LABEL = 'data-original-label'; + const ATTR_COOLDOWN = 'data-ai-cooldown-active'; + + let countdownTimer = null; + let lastFetchAt = 0; + + function buttons() { + return document.querySelectorAll(BUTTON_SELECTOR); + } + + function rememberLabel(btn) { + if (!btn.getAttribute(ATTR_LABEL)) { + const label = btn.querySelector('.ai-button-content span:last-child') || btn; + btn.setAttribute(ATTR_LABEL, label.textContent.trim()); + } + } + + function setLabel(btn, text) { + const label = btn.querySelector('.ai-button-content span:last-child'); + if (label) { + label.textContent = text; + } else { + btn.textContent = text; + } + } + + function disable(btn, seconds) { + rememberLabel(btn); + btn.setAttribute(ATTR_COOLDOWN, '1'); + btn.setAttribute('disabled', 'disabled'); + btn.classList.add('disabled'); + setLabel(btn, `Wait ${seconds}s`); + } + + function restore(btn) { + if (btn.getAttribute(ATTR_COOLDOWN) !== '1') return; + btn.removeAttribute(ATTR_COOLDOWN); + btn.removeAttribute('disabled'); + btn.classList.remove('disabled'); + const original = btn.getAttribute(ATTR_LABEL); + if (original) setLabel(btn, original); + } + + function clearTimer() { + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + } + + function applyCooldown(seconds) { + clearTimer(); + if (!seconds || seconds <= 0) { + buttons().forEach(restore); + return; + } + let remaining = seconds; + buttons().forEach(b => disable(b, remaining)); + countdownTimer = setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + clearTimer(); + buttons().forEach(restore); + return; + } + buttons().forEach(b => { + if (b.getAttribute(ATTR_COOLDOWN) === '1') setLabel(b, `Wait ${remaining}s`); + }); + }, 1000); + } + + async function fetchState() { + // Throttle to once per second to avoid hammering on chained clicks. + const now = Date.now(); + if (now - lastFetchAt < 1000) return; + lastFetchAt = now; + try { + const res = await fetch('/api/app/ai-rate-limit/state', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) return; + const data = await res.json(); + applyCooldown(Number(data.retryAfterSeconds) || 0); + } catch (_) { + // Best-effort; the server is the source of truth. + } + } + + document.addEventListener('click', (e) => { + const btn = e.target.closest(BUTTON_SELECTOR); + if (!btn) return; + // Re-check shortly after the click so a successful generate immediately + // shows the fresh 60s cooldown. + setTimeout(fetchState, 250); + }); + + document.addEventListener('DOMContentLoaded', fetchState); + if (document.readyState !== 'loading') fetchState(); +})(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs new file mode 100644 index 000000000..edba64de0 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Unity.AI.RateLimit; +using Volo.Abp; +using Volo.Abp.Users; +using Xunit; + +namespace Unity.GrantManager.AI.RateLimit; + +public class AIRateLimiterTests +{ + private readonly Guid _userId = Guid.NewGuid(); + private readonly IDistributedCache _cache = new MemoryDistributedCache( + Options.Create(new MemoryDistributedCacheOptions())); + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IConfiguration _configuration; + + public AIRateLimiterTests() + { + _currentUser.Id.Returns(_userId); + _configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AI:RateLimit:CooldownSeconds"] = "60" + }).Build(); + } + + private AIRateLimiter NewLimiter() => new(_cache, _currentUser, _configuration); + + [Fact] + public async Task GetStateAsync_Returns_Zero_When_NoCooldown() + { + var state = await NewLimiter().GetStateAsync(); + state.RetryAfterSeconds.ShouldBe(0); + } + + [Fact] + public async Task EnsureAndStampAsync_FirstCall_Stamps_AndAllowsThrough() + { + await NewLimiter().EnsureAndStampAsync(); + var state = await NewLimiter().GetStateAsync(); + state.RetryAfterSeconds.ShouldBeInRange(1, 60); + } + + [Fact] + public async Task EnsureAndStampAsync_SecondCall_Throws_WithRemainingSecondsMessage() + { + var limiter = NewLimiter(); + await limiter.EnsureAndStampAsync(); + + var ex = await Should.ThrowAsync(() => limiter.EnsureAndStampAsync()); + ex.Message.ShouldContain("rate limited"); + ex.Message.ShouldMatch(@"\d+ second"); + } + + [Fact] + public async Task GetStateAsync_Returns_Zero_For_AnonymousUser() + { + _currentUser.Id.Returns((Guid?)null); + var state = await NewLimiter().GetStateAsync(); + state.RetryAfterSeconds.ShouldBe(0); + } + + [Fact] + public async Task EnsureAndStampAsync_IsNoOp_For_AnonymousUser() + { + _currentUser.Id.Returns((Guid?)null); + await NewLimiter().EnsureAndStampAsync(); // Should not throw. + var state = await NewLimiter().GetStateAsync(); + state.RetryAfterSeconds.ShouldBe(0); + } + + [Fact] + public async Task DifferentUsers_Have_IndependentCooldowns() + { + await NewLimiter().EnsureAndStampAsync(); + + _currentUser.Id.Returns(Guid.NewGuid()); + await NewLimiter().EnsureAndStampAsync(); // Should not throw. + } + + [Fact] + public async Task ExpiredCooldown_AllowsNewStamp() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AI:RateLimit:CooldownSeconds"] = "1" + }).Build(); + var limiter = new AIRateLimiter(_cache, _currentUser, config); + + await limiter.EnsureAndStampAsync(); + await Task.Delay(TimeSpan.FromSeconds(1.2), CancellationToken.None); + await limiter.EnsureAndStampAsync(); // Should not throw. + } +} 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 45dcdf556..7ac1b51b7 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 @@ -299,10 +299,13 @@ private static ApplicationAIGenerationQueue CreateQueue( IBackgroundJobManager backgroundJobManager, IRepository repository) { + var rateLimiter = Substitute.For(); + rateLimiter.EnsureAndStampAsync().Returns(Task.CompletedTask); return new ApplicationAIGenerationQueue( backgroundJobManager, repository, new TestDistributedLockProvider(), + rateLimiter, Substitute.For>()); } } From 837bead29511f54de09ac28e1b600bceaf96c5dc Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 17:01:22 -0700 Subject: [PATCH 03/21] AB#32290 simplify AI generation throttling --- .../RateLimit/AIRateLimiter.cs | 21 ++-- .../Unity.AI.Application.csproj | 1 + .../ApplicationAIGenerationQueue.cs | 8 +- .../Pages/GrantApplications/ai-analysis.js | 111 +++++++++--------- .../ai-generation-button-state.js | 73 ++++++++++-- .../Pages/GrantApplications/ai-rate-limit.js | 8 +- .../AssessmentScoresWidget/Default.js | 57 ++------- .../Components/ReviewList/ReviewList.js | 111 ++++++++---------- .../AI/RateLimit/AIRateLimiterTests.cs | 76 +++++++++++- .../Automation/AIGenerationQueueTests.cs | 10 +- 10 files changed, 284 insertions(+), 192 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs index 24a4b22e8..5317850ae 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; +using Medallion.Threading; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; using Volo.Abp; @@ -18,9 +19,11 @@ namespace Unity.AI.RateLimit; public class AIRateLimiter( IDistributedCache cache, ICurrentUser currentUser, - IConfiguration configuration) : IAIRateLimiter, ITransientDependency + IConfiguration configuration, + IDistributedLockProvider distributedLockProvider) : IAIRateLimiter, ITransientDependency { private const string KeyPrefix = "AI:Cooldown:"; + private const string LockPrefix = "AI:CooldownLock:"; private const int DefaultCooldownSeconds = 60; private int CooldownSeconds => @@ -34,14 +37,18 @@ public virtual async Task EnsureAndStampAsync() return; } - var remaining = await GetRemainingSecondsAsync(userId); - if (remaining > 0) + var userLock = distributedLockProvider.CreateLock(LockPrefix + userId); + using (await userLock.AcquireAsync()) { - throw new UserFriendlyException( - $"AI generation is rate limited. Try again in {remaining} second{(remaining == 1 ? "" : "s")}."); - } + var remaining = await GetRemainingSecondsAsync(userId); + if (remaining > 0) + { + throw new UserFriendlyException( + $"AI generation is rate limited. Try again in {remaining} second{(remaining == 1 ? "" : "s")}."); + } - await StampAsync(userId, CooldownSeconds); + await StampAsync(userId, CooldownSeconds); + } } public virtual async Task GetStateAsync() diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj index 4f5521639..78d3c1414 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj @@ -14,6 +14,7 @@ + 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 ea3e6d678..3e96ae75e 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 @@ -108,10 +108,6 @@ private async Task EnsureRequestAndEnqueueAsync( Guid? applicationId, Func enqueue) { - // Single chokepoint for all AI generate flows (manual + auto). - // The limiter is a no-op for system/background callers without an authenticated user. - await aiRateLimiter.EnsureAndStampAsync(); - var requestLock = distributedLockProvider.CreateLock($"ai-generation:{requestKey}"); using (await requestLock.AcquireAsync()) @@ -131,6 +127,10 @@ private async Task EnsureRequestAndEnqueueAsync( return; } + // Single chokepoint for all AI generate flows (manual + auto). + // The limiter is a no-op for system/background callers without an authenticated user. + await aiRateLimiter.EnsureAndStampAsync(); + var request = new AIGenerationRequest( Guid.NewGuid(), tenantId, 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 885a705ca..322acadab 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 @@ -10,6 +10,10 @@ const dismissedSectionVisibility = { recommendation: false }; +const aiAnalysisPollIntervalMs = 15000; +const aiAnalysisMaxPollFailures = 3; +let aiAnalysisMonitor = null; + function getAnalysisLabels() { const labels = document.getElementById('aiAnalysisLabels')?.dataset ?? {}; @@ -415,84 +419,58 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { const applicationId = $('#DetailsViewApplicationId').val(); const $button = triggerButton ? $(triggerButton) : $('#regenerateApplicationAnalysis'); const existingHtml = $button.html(); - const aiAnalysisPollIntervalMs = 15000; - const aiAnalysisMaxPollFailures = 3; if (!applicationId || $button.prop('disabled')) { return; } - $button - .html('Generating...') - .prop('disabled', true); globalThis.AIGenerationButtonState?.setGenerating($button); - let aiAnalysisPollTimeoutId = null; - let aiAnalysisPollFailures = 0; - const stopAIAnalysisPolling = function() { - if (aiAnalysisPollTimeoutId) { - clearTimeout(aiAnalysisPollTimeoutId); - aiAnalysisPollTimeoutId = null; - } - }; - - const poll = function() { - unity.grantManager.grantApplications.grantApplication - .getAIGenerationStatus(applicationId, 'application-analysis') - .done(function(request) { - aiAnalysisPollFailures = 0; - const statusText = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; - - if (statusText === 'Failed') { - stopAIAnalysisPolling(); - loadAIAnalysis(); - globalThis.AIGenerationButtonState?.restore($button); - $button.html(existingHtml).prop('disabled', false); - abp.message.error(request?.failureReason || 'AI analysis failed.'); - return; - } - - if (!request || request.isActive === false || statusText === 'Completed') { - stopAIAnalysisPolling(); - loadAIAnalysis(); - globalThis.AIGenerationButtonState?.setCompleted($button); - $button.html('Completed').prop('disabled', true); - return; - } - - aiAnalysisPollTimeoutId = setTimeout(poll, aiAnalysisPollIntervalMs); - }) - .fail(function(error) { - console.warn('Failed to poll AI analysis status.', error); - aiAnalysisPollFailures += 1; + unity.grantManager.grantApplications.grantApplication + .queueApplicationAnalysis(applicationId) + .done(function(request) { + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; - if (aiAnalysisPollFailures > aiAnalysisMaxPollFailures) { - stopAIAnalysisPolling(); - $button.html(existingHtml).prop('disabled', false); - abp.message.error('Unable to load AI analysis status. Please try again.'); + if (status === 'Completed') { + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); + loadAIAnalysis(); + globalThis.refreshAIRateLimitState?.(); return; } - aiAnalysisPollTimeoutId = setTimeout(poll, aiAnalysisPollIntervalMs); - }); - }; - - unity.grantManager.grantApplications.grantApplication - .queueApplicationAnalysis(applicationId) - .done(function(request) { - aiAnalysisPollFailures = 0; - stopAIAnalysisPolling(); - aiAnalysisPollTimeoutId = setTimeout(poll, 500); + monitorAIAnalysisGeneration(applicationId, $button, existingHtml); }) .fail(function(error) { console.error('Failed to queue AI analysis.', error); - stopAIAnalysisPolling(); + aiAnalysisMonitor?.stop(); globalThis.AIGenerationButtonState?.restore($button); $button.html(existingHtml).prop('disabled', false); abp.message.error('Failed to queue AI analysis. Please try again.'); }); } +function monitorAIAnalysisGeneration(applicationId, $button, existingHtml) { + aiAnalysisMonitor?.stop(); + aiAnalysisMonitor = globalThis.AIGenerationButtonState.monitor({ + $button, + originalHtml: existingHtml, + intervalMs: aiAnalysisPollIntervalMs, + maxFailures: aiAnalysisMaxPollFailures, + getStatus: () => unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(applicationId, 'application-analysis'), + onComplete: loadAIAnalysis, + onFailed: (request) => { + loadAIAnalysis(); + abp.message.error(request?.failureReason || 'AI analysis failed.'); + }, + onPollFailed: (error) => { + console.warn('Failed to poll AI analysis status.', error); + abp.message.error('Unable to load AI analysis status. Please try again.'); + } + }); +} + function loadAIAnalysis() { if ($('#AIAnalysisFeatureEnabled').val() === 'False') { return; @@ -531,5 +509,22 @@ $(function() { $regenerateButton.on('click', function() { queueApplicationAnalysis(); }); + + const applicationId = $('#DetailsViewApplicationId').val(); + if (!applicationId) { + return; + } + + unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(applicationId, 'application-analysis') + .done(function(request) { + if (request?.isActive !== true) { + return; + } + + const existingHtml = $regenerateButton.html(); + globalThis.AIGenerationButtonState?.setGenerating($regenerateButton); + monitorAIAnalysisGeneration(applicationId, $regenerateButton, existingHtml); + }); } }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js index a3c78ec4b..688d7bcac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js @@ -6,16 +6,15 @@ opacity: '1', }; - const completedStyles = { - 'border-color': '#2e7d32', - color: '#2e7d32', - opacity: '1', - }; - function applyStyles($button, styles) { $button.css(styles); } + function restoreButton($button, html) { + global.AIGenerationButtonState.restore($button); + $button.html(html).prop('disabled', false); + } + global.AIGenerationButtonState = { resolveStatus(status) { switch (Number(status)) { @@ -32,12 +31,15 @@ } }, setGenerating($button) { + $button.removeAttr('data-ai-cooldown-active'); + $button.attr('data-ai-generating', '1'); + $button + .html('Generating...') + .prop('disabled', true); applyStyles($button, generatingStyles); }, - setCompleted($button) { - applyStyles($button, completedStyles); - }, restore($button) { + $button.removeAttr('data-ai-generating'); $button.css({ 'background-color': '', 'border-color': '', @@ -45,5 +47,58 @@ opacity: '', }).removeClass('disabled'); }, + monitor(options) { + const intervalMs = options.intervalMs || 15000; + const maxFailures = options.maxFailures || 3; + let timeoutId = null; + let failures = 0; + + const stop = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const poll = () => { + options.getStatus() + .done((request) => { + failures = 0; + const status = this.resolveStatus(request?.status); + + if (status === 'Failed') { + stop(); + restoreButton(options.$button, options.originalHtml); + options.onFailed?.(request); + return; + } + + if (!request || request.isActive === false || status === 'Completed') { + stop(); + restoreButton(options.$button, options.originalHtml); + options.onComplete?.(request); + global.refreshAIRateLimitState?.(); + return; + } + + timeoutId = setTimeout(poll, intervalMs); + }) + .fail((error) => { + failures += 1; + if (failures > maxFailures) { + stop(); + restoreButton(options.$button, options.originalHtml); + options.onPollFailed?.(error); + return; + } + + timeoutId = setTimeout(poll, intervalMs); + }); + }; + + stop(); + timeoutId = setTimeout(poll, options.initialDelayMs ?? 500); + return { stop }; + }, }; })(globalThis); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js index e43fc5fb1..7d9ddfc5d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js @@ -1,4 +1,4 @@ -/* AB#32300 — per-user 60s cooldown for AI Generate buttons. +/* AB#32290 — per-user 60s cooldown for AI Generate buttons. * Server stamps the cooldown; this module only mirrors that state in the UI. * Strategy: on load, fetch the user's remaining seconds and disable every * .ai-generate-btn with a countdown. After any generate click resolves we @@ -34,6 +34,10 @@ } function disable(btn, seconds) { + if (btn.getAttribute('data-ai-generating') === '1') { + return; + } + rememberLabel(btn); btn.setAttribute(ATTR_COOLDOWN, '1'); btn.setAttribute('disabled', 'disabled'); @@ -96,6 +100,8 @@ } } + globalThis.refreshAIRateLimitState = fetchState; + document.addEventListener('click', (e) => { const btn = e.target.closest(BUTTON_SELECTOR); if (!btn) return; 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 b697de046..23f509ab4 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 @@ -584,55 +584,21 @@ function queueApplicationScoring(triggerButton = null) { const applicationId = $('#DetailsViewApplicationId').val(); const $button = triggerButton ? $(triggerButton) : $('#regenerateAiScoresheetBtn'); const existingHtml = $button.html(); - const aiGenerationPollIntervalMs = 15000; - let aiGenerationPollTimeoutId = null; if (!applicationId || $button.prop('disabled')) { return; } - $button - .html( - 'Generating...' - ) - .prop('disabled', true); globalThis.AIGenerationButtonState?.setGenerating($button); - const stopPolling = function () { - if (aiGenerationPollTimeoutId) { - clearTimeout(aiGenerationPollTimeoutId); - aiGenerationPollTimeoutId = null; - } - }; - - const poll = function () { - unity.grantManager.grantApplications.grantApplication - .getAIGenerationStatus(applicationId, 'application-scoring') - .done(function (request) { - const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; - - if (status === 'Failed') { - stopPolling(); - abp.message.error(request?.failureReason || 'AI scoring failed.'); - globalThis.AIGenerationButtonState?.restore($button); - $button.html(existingHtml).prop('disabled', false); - return; - } - - if (!request || request.isActive === false || status === 'Completed') { - stopPolling(); - globalThis.AIGenerationButtonState?.setCompleted($button); - $button.html('Completed').prop('disabled', true); - PubSub.publish('refresh_assessment_scores', null); - return; - } - - aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); - }) - .fail(function () { - aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); - }); - }; + const monitorScoring = () => globalThis.AIGenerationButtonState.monitor({ + $button, + originalHtml: existingHtml, + getStatus: () => unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(applicationId, 'application-scoring'), + onComplete: () => PubSub.publish('refresh_assessment_scores', null), + onFailed: (request) => abp.message.error(request?.failureReason || 'AI scoring failed.') + }); unity.grantManager.grantApplications.grantApplication .queueApplicationScoring(applicationId) @@ -640,15 +606,16 @@ function queueApplicationScoring(triggerButton = null) { const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Completed') { - $button.html('Completed').prop('disabled', true); + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); PubSub.publish('refresh_assessment_scores', null); + globalThis.refreshAIRateLimitState?.(); return; } - aiGenerationPollTimeoutId = setTimeout(poll, 500); + monitorScoring(); }) .fail(function () { - stopPolling(); abp.message.error( 'Failed to queue AI scoring. Please try again.' ); 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 0da7f980c..3afdeb24f 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 @@ -233,11 +233,12 @@ $(function () { CreateAssessmentButton(); } - function GenerateAiAssessmentButton() { - let generateButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentGenerateButtonGroup); - generateButtons.container().appendTo("#AdjudicationTeamLeadActionBar"); - reviewListTable.buttons('Generate:name').enable(); - } + function GenerateAiAssessmentButton() { + let generateButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentGenerateButtonGroup); + generateButtons.container().appendTo("#AdjudicationTeamLeadActionBar"); + reviewListTable.buttons('Generate:name').enable(); + resumeActiveReviewListAiButton(reviewListTable); + } async function CreateAssessmentButton() { let createButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentCreateButtonGroup); @@ -457,84 +458,68 @@ function unityWorkflowButtonAction(e, dt, button, config) { function generateAiButtonAction(e, dt, button, config) { const $button = button?.node ? $(button.node) : 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') - .done(function (request) { - const status = globalThis.AIGenerationButtonState?.resolveStatus(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) .done(function (request) { const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Completed') { - setReviewListAiButtonCompleted($button); + restoreReviewListAiButton($button); refreshReviewListAfterAiScoring(); + globalThis.refreshAIRateLimitState?.(); return; } - aiGenerationPollTimeoutId = setTimeout(poll, 500); + pollReviewListAiButton($button); }) .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)); - } + restoreReviewListAiButton($button); }) ; } -function setReviewListAiButtonCompleted($button) { +function restoreReviewListAiButton($button) { if (!$button?.length) { return; } - globalThis.AIGenerationButtonState?.setCompleted($button); - $button.html('Completed').prop('disabled', true); + globalThis.AIGenerationButtonState?.restore($button); + $button.html(generateAiButtonText(null, null, null)).prop('disabled', false); +} + +function resumeActiveReviewListAiButton(reviewListTable) { + const button = reviewListTable.button('Generate:name'); + if (!button?.any()) { + return; + } + + const $button = $(button.node()); + unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(pageApplicationId, 'application-scoring') + .done(function(request) { + if (request?.isActive !== true) { + return; + } + + globalThis.AIGenerationButtonState?.setGenerating($button); + pollReviewListAiButton($button); + }); +} + +function pollReviewListAiButton($button) { + globalThis.AIGenerationButtonState.monitor({ + $button, + originalHtml: generateAiButtonText(null, null, null), + getStatus: () => unity.grantManager.grantApplications.grantApplication + .getAIGenerationStatus(pageApplicationId, 'application-scoring'), + onComplete: refreshReviewListAfterAiScoring, + onFailed: (request) => abp.message.error(request?.failureReason || 'AI scoring failed.') + }); } function refreshReviewListAfterAiScoring() { @@ -546,11 +531,11 @@ 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( - "Completed Successfully", - l(`Enum:AssessmentAction.${triggerAction}`) - ); + PubSub.publish('refresh_review_list', assessmentId); + abp.notify.success( + "Completed Successfully", + l(`Enum:AssessmentAction.${triggerAction}`) + ); }); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs index edba64de0..ec03a713a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Medallion.Threading; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; @@ -33,7 +35,7 @@ public AIRateLimiterTests() }).Build(); } - private AIRateLimiter NewLimiter() => new(_cache, _currentUser, _configuration); + private AIRateLimiter NewLimiter() => new(_cache, _currentUser, _configuration, new TestDistributedLockProvider()); [Fact] public async Task GetStateAsync_Returns_Zero_When_NoCooldown() @@ -87,6 +89,18 @@ public async Task DifferentUsers_Have_IndependentCooldowns() await NewLimiter().EnsureAndStampAsync(); // Should not throw. } + [Fact] + public async Task ConcurrentCalls_ForSameUser_OnlyAllowOneThrough() + { + var limiter = NewLimiter(); + + var results = await Task.WhenAll( + TryEnsureAndStampAsync(limiter), + TryEnsureAndStampAsync(limiter)); + + results.Count(x => x).ShouldBe(1); + } + [Fact] public async Task ExpiredCooldown_AllowsNewStamp() { @@ -95,10 +109,68 @@ public async Task ExpiredCooldown_AllowsNewStamp() { ["AI:RateLimit:CooldownSeconds"] = "1" }).Build(); - var limiter = new AIRateLimiter(_cache, _currentUser, config); + var limiter = new AIRateLimiter(_cache, _currentUser, config, new TestDistributedLockProvider()); await limiter.EnsureAndStampAsync(); await Task.Delay(TimeSpan.FromSeconds(1.2), CancellationToken.None); await limiter.EnsureAndStampAsync(); // Should not throw. } + + private static async Task TryEnsureAndStampAsync(AIRateLimiter limiter) + { + try + { + await limiter.EnsureAndStampAsync(); + return true; + } + catch (UserFriendlyException) + { + return false; + } + } + + private sealed class TestDistributedLockProvider : IDistributedLockProvider + { + public IDistributedLock CreateLock(string name) => new TestDistributedLock(name); + } + + private sealed class TestDistributedLock(string name) : IDistributedLock + { + private static readonly SemaphoreSlim Gate = new(1, 1); + + public string Name => name; + + public IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + Gate.Wait(cancellationToken); + return new TestDistributedSynchronizationHandle(Gate); + } + + public async ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + await Gate.WaitAsync(cancellationToken); + return new TestDistributedSynchronizationHandle(Gate); + } + + public IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + Gate.Wait(timeout, cancellationToken) ? new TestDistributedSynchronizationHandle(Gate) : null; + + public async ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + await Gate.WaitAsync(timeout, cancellationToken) + ? new TestDistributedSynchronizationHandle(Gate) + : null; + } + + private sealed class TestDistributedSynchronizationHandle(SemaphoreSlim gate) : IDistributedSynchronizationHandle + { + public CancellationToken HandleLostToken => CancellationToken.None; + + public void Dispose() => gate.Release(); + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } } 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 7ac1b51b7..f48dbc7d9 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 @@ -78,12 +78,15 @@ public async Task QueueApplicationAnalysisAsync_Should_Not_Enqueue_When_An_Activ repository.GetQueryableAsync().Returns(Task.FromResult>(new[] { request }.AsQueryable())); var backgroundJobManager = Substitute.For(); - var queue = CreateQueue(backgroundJobManager, repository); + var rateLimiter = Substitute.For(); + rateLimiter.EnsureAndStampAsync().Returns(Task.CompletedTask); + var queue = CreateQueue(backgroundJobManager, repository, rateLimiter); await queue.QueueApplicationAnalysisAsync(applicationId, tenantId, promptVersion); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any()); await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await rateLimiter.DidNotReceive().EnsureAndStampAsync(); } [Fact] @@ -297,9 +300,10 @@ public ValueTask DisposeAsync() private static ApplicationAIGenerationQueue CreateQueue( IBackgroundJobManager backgroundJobManager, - IRepository repository) + IRepository repository, + Unity.AI.RateLimit.IAIRateLimiter? rateLimiter = null) { - var rateLimiter = Substitute.For(); + rateLimiter ??= Substitute.For(); rateLimiter.EnsureAndStampAsync().Returns(Task.CompletedTask); return new ApplicationAIGenerationQueue( backgroundJobManager, From 6581544f0fa249f41a20bc8aede40e9ea5ff1d1f Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 5 May 2026 13:22:49 -0700 Subject: [PATCH 04/21] AB#32290 start cooldown after AI generation completes --- .../RateLimit/IAIRateLimiter.cs | 15 ++++- .../RateLimit/AIRateLimiter.cs | 15 ++++- .../ApplicationAIGenerationQueue.cs | 2 +- .../AIGenerationRequestJobHelper.cs | 3 +- .../GenerateApplicationAnalysisJob.cs | 5 +- .../GenerateApplicationScoringJob.cs | 6 +- .../GenerateAttachmentSummaryJob.cs | 6 +- .../RunApplicationAIPipelineJob.cs | 8 ++- .../Pages/GrantApplications/ai-rate-limit.js | 6 +- .../AI/RateLimit/AIRateLimiterTests.cs | 59 +++++++++---------- .../Automation/AIGenerationQueueTests.cs | 6 +- .../RunApplicationAIPipelineJobTests.cs | 2 + 12 files changed, 85 insertions(+), 48 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs index 0755ab0c2..cf2317e2e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using System; namespace Unity.AI.RateLimit; @@ -6,9 +7,19 @@ public interface IAIRateLimiter { /// /// Throws if the current user is still - /// inside their AI generate cooldown window, otherwise stamps a fresh cooldown. + /// inside their AI generate cooldown window. /// - Task EnsureAndStampAsync(); + Task EnsureAsync(); + + /// + /// Starts a fresh cooldown for the current user. No-op for callers without a user. + /// + Task StampAsync(); + + /// + /// Starts a fresh cooldown for the supplied user. No-op when userId is null. + /// + Task StampAsync(Guid? userId); /// /// Returns the remaining cooldown for the current user. RetryAfterSeconds is 0 when diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs index 5317850ae..db3b0cc82 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -29,7 +29,7 @@ public class AIRateLimiter( private int CooldownSeconds => configuration.GetValue("AI:RateLimit:CooldownSeconds") ?? DefaultCooldownSeconds; - public virtual async Task EnsureAndStampAsync() + public virtual async Task EnsureAsync() { if (currentUser.Id is not Guid userId) { @@ -46,8 +46,19 @@ public virtual async Task EnsureAndStampAsync() throw new UserFriendlyException( $"AI generation is rate limited. Try again in {remaining} second{(remaining == 1 ? "" : "s")}."); } + } + } - await StampAsync(userId, CooldownSeconds); + public virtual async Task StampAsync() + { + await StampAsync(currentUser.Id); + } + + public virtual async Task StampAsync(Guid? userId) + { + if (userId is Guid resolvedUserId) + { + await StampAsync(resolvedUserId, CooldownSeconds); } } 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 3e96ae75e..b14c108f2 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 @@ -129,7 +129,7 @@ private async Task EnsureRequestAndEnqueueAsync( // Single chokepoint for all AI generate flows (manual + auto). // The limiter is a no-op for system/background callers without an authenticated user. - await aiRateLimiter.EnsureAndStampAsync(); + await aiRateLimiter.EnsureAsync(); var request = new AIGenerationRequest( Guid.NewGuid(), 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 978b3d587..0de8c28aa 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 @@ -61,7 +61,7 @@ public static async Task MarkRunningInNewUowAsync( await uow.CompleteAsync(); } - public static async Task MarkCompletedInNewUowAsync( + public static async Task MarkCompletedInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, string requestKey) @@ -70,6 +70,7 @@ public static async Task MarkCompletedInNewUowAsync( var request = await GetLatestRequestAsync(generationRequestRepository, x => x.RequestKey == requestKey); await MarkCompletedAsync(generationRequestRepository, request); await uow.CompleteAsync(); + return request?.CreatorId; } public static async Task MarkFailedInNewUowAsync( 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 b7a78850a..6026e8b53 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -2,6 +2,7 @@ using System; using System.Threading.Tasks; using Unity.AI.Operations; +using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; @@ -16,6 +17,7 @@ public class GenerateApplicationAnalysisJob( IRepository generationRequestRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, + IAIRateLimiter aiRateLimiter, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args) @@ -29,7 +31,8 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId); - await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(creatorId); } catch (Exception ex) { 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 aa6bcb749..98e29364e 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,7 @@ using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.Events; using Volo.Abp.BackgroundJobs; @@ -18,6 +19,7 @@ public class GenerateApplicationScoringJob( ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, ILocalEventBus localEventBus, + IAIRateLimiter aiRateLimiter, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args) @@ -37,7 +39,9 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent }); } logger.LogInformation("Completed AI application scoring job for application {ApplicationId}.", args.ApplicationId); - await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(creatorId); } catch (Exception ex) { 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 b9a710478..84895f947 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs @@ -2,6 +2,7 @@ using System; using System.Threading.Tasks; using Unity.AI.Operations; +using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; @@ -16,6 +17,7 @@ public class GenerateAttachmentSummaryJob( IRepository generationRequestRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, + IAIRateLimiter aiRateLimiter, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args) @@ -29,7 +31,9 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr "Executing AI attachment summary job for application {ApplicationId}.", args.ApplicationId); await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); - await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(creatorId); } catch (Exception ex) { 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 9ecd21a82..204727eea 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 @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.AI.RateLimit; using Unity.GrantManager.Applications; using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications; @@ -27,6 +28,7 @@ public class RunApplicationAIPipelineJob( IRepository generationRequestRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, + IAIRateLimiter aiRateLimiter, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) @@ -49,7 +51,8 @@ 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 AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(creatorId); return; } @@ -112,7 +115,8 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent throw analysisException; } - await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var completedCreatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(completedCreatorId); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js index 7d9ddfc5d..330cd290c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js @@ -82,10 +82,10 @@ }, 1000); } - async function fetchState() { + async function fetchState(force = false) { // Throttle to once per second to avoid hammering on chained clicks. const now = Date.now(); - if (now - lastFetchAt < 1000) return; + if (!force && now - lastFetchAt < 1000) return; lastFetchAt = now; try { const res = await fetch('/api/app/ai-rate-limit/state', { @@ -100,7 +100,7 @@ } } - globalThis.refreshAIRateLimitState = fetchState; + globalThis.refreshAIRateLimitState = () => fetchState(true); document.addEventListener('click', (e) => { const btn = e.target.closest(BUTTON_SELECTOR); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs index ec03a713a..9d07ef896 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Medallion.Threading; @@ -45,20 +44,29 @@ public async Task GetStateAsync_Returns_Zero_When_NoCooldown() } [Fact] - public async Task EnsureAndStampAsync_FirstCall_Stamps_AndAllowsThrough() + public async Task EnsureAsync_AllowsThrough_When_NoCooldown() { - await NewLimiter().EnsureAndStampAsync(); + await NewLimiter().EnsureAsync(); var state = await NewLimiter().GetStateAsync(); + state.RetryAfterSeconds.ShouldBe(0); + } + + [Fact] + public async Task StampAsync_Starts_Cooldown() + { + var limiter = NewLimiter(); + await limiter.StampAsync(); + var state = await limiter.GetStateAsync(); state.RetryAfterSeconds.ShouldBeInRange(1, 60); } [Fact] - public async Task EnsureAndStampAsync_SecondCall_Throws_WithRemainingSecondsMessage() + public async Task EnsureAsync_Throws_When_Cooldown_Exists() { var limiter = NewLimiter(); - await limiter.EnsureAndStampAsync(); + await limiter.StampAsync(); - var ex = await Should.ThrowAsync(() => limiter.EnsureAndStampAsync()); + var ex = await Should.ThrowAsync(() => limiter.EnsureAsync()); ex.Message.ShouldContain("rate limited"); ex.Message.ShouldMatch(@"\d+ second"); } @@ -72,10 +80,10 @@ public async Task GetStateAsync_Returns_Zero_For_AnonymousUser() } [Fact] - public async Task EnsureAndStampAsync_IsNoOp_For_AnonymousUser() + public async Task EnsureAsync_IsNoOp_For_AnonymousUser() { _currentUser.Id.Returns((Guid?)null); - await NewLimiter().EnsureAndStampAsync(); // Should not throw. + await NewLimiter().EnsureAsync(); // Should not throw. var state = await NewLimiter().GetStateAsync(); state.RetryAfterSeconds.ShouldBe(0); } @@ -83,22 +91,24 @@ public async Task EnsureAndStampAsync_IsNoOp_For_AnonymousUser() [Fact] public async Task DifferentUsers_Have_IndependentCooldowns() { - await NewLimiter().EnsureAndStampAsync(); + await NewLimiter().StampAsync(); _currentUser.Id.Returns(Guid.NewGuid()); - await NewLimiter().EnsureAndStampAsync(); // Should not throw. + await NewLimiter().EnsureAsync(); // Should not throw. } [Fact] - public async Task ConcurrentCalls_ForSameUser_OnlyAllowOneThrough() + public async Task StampAsync_ForSuppliedUser_Starts_Cooldown_ForThatUser() { - var limiter = NewLimiter(); + var otherUserId = Guid.NewGuid(); + + await NewLimiter().StampAsync(otherUserId); - var results = await Task.WhenAll( - TryEnsureAndStampAsync(limiter), - TryEnsureAndStampAsync(limiter)); + await NewLimiter().EnsureAsync(); // Current user should not be blocked. - results.Count(x => x).ShouldBe(1); + _currentUser.Id.Returns(otherUserId); + var ex = await Should.ThrowAsync(() => NewLimiter().EnsureAsync()); + ex.Message.ShouldContain("rate limited"); } [Fact] @@ -111,22 +121,9 @@ public async Task ExpiredCooldown_AllowsNewStamp() }).Build(); var limiter = new AIRateLimiter(_cache, _currentUser, config, new TestDistributedLockProvider()); - await limiter.EnsureAndStampAsync(); + await limiter.StampAsync(); await Task.Delay(TimeSpan.FromSeconds(1.2), CancellationToken.None); - await limiter.EnsureAndStampAsync(); // Should not throw. - } - - private static async Task TryEnsureAndStampAsync(AIRateLimiter limiter) - { - try - { - await limiter.EnsureAndStampAsync(); - return true; - } - catch (UserFriendlyException) - { - return false; - } + await limiter.EnsureAsync(); // Should not throw. } private sealed class TestDistributedLockProvider : IDistributedLockProvider 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 f48dbc7d9..a74b78655 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 @@ -79,14 +79,14 @@ public async Task QueueApplicationAnalysisAsync_Should_Not_Enqueue_When_An_Activ var backgroundJobManager = Substitute.For(); var rateLimiter = Substitute.For(); - rateLimiter.EnsureAndStampAsync().Returns(Task.CompletedTask); + rateLimiter.EnsureAsync().Returns(Task.CompletedTask); var queue = CreateQueue(backgroundJobManager, repository, rateLimiter); await queue.QueueApplicationAnalysisAsync(applicationId, tenantId, promptVersion); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any()); await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await rateLimiter.DidNotReceive().EnsureAndStampAsync(); + await rateLimiter.DidNotReceive().EnsureAsync(); } [Fact] @@ -304,7 +304,7 @@ private static ApplicationAIGenerationQueue CreateQueue( Unity.AI.RateLimit.IAIRateLimiter? rateLimiter = null) { rateLimiter ??= Substitute.For(); - rateLimiter.EnsureAndStampAsync().Returns(Task.CompletedTask); + rateLimiter.EnsureAsync().Returns(Task.CompletedTask); return new ApplicationAIGenerationQueue( backgroundJobManager, repository, 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 c8ef48d05..fecaae260 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 @@ -9,6 +9,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; +using Unity.AI.RateLimit; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus.Local; using Volo.Abp.Features; @@ -97,6 +98,7 @@ private RunApplicationAIPipelineJob BuildJob( generationRequestRepository, Substitute.For(), GetRequiredService(), + Substitute.For(), NullLogger.Instance); } From 5d6eb6ebc2229f82091e0e642c085bcd0d45ea68 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 6 May 2026 11:39:54 -0700 Subject: [PATCH 05/21] AB#32290 clean up AI generation cooldown flow --- .../Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs | 2 +- .../BackgroundJobs/GenerateApplicationAnalysisJob.cs | 2 +- .../BackgroundJobs/GenerateApplicationScoringJob.cs | 2 +- .../Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs | 2 +- .../Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) 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 0de8c28aa..633ce6a5c 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 @@ -61,7 +61,7 @@ public static async Task MarkRunningInNewUowAsync( await uow.CompleteAsync(); } - public static async Task MarkCompletedInNewUowAsync( + public static async Task MarkCompletedInNewUowAndGetCreatorIdAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, string requestKey) 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 6026e8b53..5c02f1380 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 @@ -31,7 +31,7 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); await aiRateLimiter.StampAsync(creatorId); } catch (Exception ex) 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 98e29364e..f3d5d9af9 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 @@ -40,7 +40,7 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent } logger.LogInformation("Completed AI application scoring job for application {ApplicationId}.", args.ApplicationId); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); await aiRateLimiter.StampAsync(creatorId); } catch (Exception ex) 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 84895f947..b79b83ad5 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 @@ -32,7 +32,7 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr args.ApplicationId); await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); await aiRateLimiter.StampAsync(creatorId); } catch (Exception ex) 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 204727eea..7bc807768 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 @@ -51,7 +51,7 @@ 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); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); await aiRateLimiter.StampAsync(creatorId); return; } @@ -115,7 +115,7 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent throw analysisException; } - var completedCreatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + var completedCreatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); await aiRateLimiter.StampAsync(completedCreatorId); } catch (Exception ex) From 8d4db6516c74cca2645d91e3966a7870acb7b893 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 6 May 2026 14:49:47 -0700 Subject: [PATCH 06/21] AB#32290 carry requester through AI jobs --- ...enerateApplicationAnalysisBackgroundJobArgs.cs | 1 + ...GenerateApplicationScoringBackgroundJobArgs.cs | 1 + .../GenerateAttachmentSummaryBackgroundJobArgs.cs | 1 + .../RunApplicationAIPipelineJobArgs.cs | 1 + .../Automation/ApplicationAIGenerationQueue.cs | 6 ++++++ .../AIGenerationRequestJobHelper.cs | 3 +-- .../GenerateApplicationAnalysisJob.cs | 4 ++-- .../GenerateApplicationScoringJob.cs | 4 ++-- .../GenerateAttachmentSummaryJob.cs | 4 ++-- .../BackgroundJobs/RunApplicationAIPipelineJob.cs | 8 ++++---- .../Automation/AIGenerationQueueTests.cs | 15 +++++++++++++++ .../RunApplicationAIPipelineJobTests.cs | 12 ++++++++++-- 12 files changed, 46 insertions(+), 14 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs index 5c0f79d2d..5f8979af4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs @@ -4,6 +4,7 @@ public class GenerateApplicationAnalysisBackgroundJobArgs { public Guid ApplicationId { get; set; } public Guid? TenantId { get; set; } + public Guid? RequestedByUserId { get; set; } public string? PromptVersion { get; set; } public string RequestKey { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs index 3a1bdd1d5..8ac67c4d4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs @@ -4,6 +4,7 @@ public class GenerateApplicationScoringBackgroundJobArgs { public Guid ApplicationId { get; set; } public Guid? TenantId { get; set; } + public Guid? RequestedByUserId { get; set; } public string? PromptVersion { get; set; } public string RequestKey { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs index b75c5d3e0..af4a185c6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs @@ -4,6 +4,7 @@ public class GenerateAttachmentSummaryBackgroundJobArgs { public Guid ApplicationId { get; set; } public Guid? TenantId { get; set; } + public Guid? RequestedByUserId { get; set; } public string? PromptVersion { get; set; } public string RequestKey { get; set; } = string.Empty; } 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 3f5fee559..55b88431b 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 @@ -6,6 +6,7 @@ public class RunApplicationAIPipelineJobArgs { public Guid ApplicationId { get; set; } public Guid? TenantId { get; set; } + public Guid? RequestedByUserId { get; set; } public string? PromptVersion { 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 b14c108f2..3c42325d0 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 @@ -10,6 +10,7 @@ using Volo.Abp.Domain.Repositories; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; +using Volo.Abp.Users; namespace Unity.GrantManager.GrantApplications.Automation; @@ -18,6 +19,7 @@ public class ApplicationAIGenerationQueue( IRepository generationRequestRepository, IDistributedLockProvider distributedLockProvider, IAIRateLimiter aiRateLimiter, + ICurrentUser currentUser, ILogger logger) : IApplicationAIGenerationQueue, ITransientDependency { @@ -35,6 +37,7 @@ await EnsureRequestAndEnqueueAsync( { ApplicationId = applicationId, PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, TenantId = tenantId, RequestKey = requestKey }); @@ -55,6 +58,7 @@ await EnsureRequestAndEnqueueAsync( { ApplicationId = applicationId, PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, TenantId = tenantId, RequestKey = requestKey }); @@ -75,6 +79,7 @@ await EnsureRequestAndEnqueueAsync( { ApplicationId = applicationId, PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, TenantId = tenantId, RequestKey = requestKey }); @@ -95,6 +100,7 @@ await EnsureRequestAndEnqueueAsync( { ApplicationId = applicationId, PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, TenantId = tenantId, RequestKey = requestKey }); 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 633ce6a5c..978b3d587 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 @@ -61,7 +61,7 @@ public static async Task MarkRunningInNewUowAsync( await uow.CompleteAsync(); } - public static async Task MarkCompletedInNewUowAndGetCreatorIdAsync( + public static async Task MarkCompletedInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, string requestKey) @@ -70,7 +70,6 @@ public static async Task MarkRunningInNewUowAsync( var request = await GetLatestRequestAsync(generationRequestRepository, x => x.RequestKey == requestKey); await MarkCompletedAsync(generationRequestRepository, request); await uow.CompleteAsync(); - return request?.CreatorId; } public static async Task MarkFailedInNewUowAsync( 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 5c02f1380..77cf24468 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 @@ -31,8 +31,8 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(creatorId); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(args.RequestedByUserId); } catch (Exception ex) { 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 f3d5d9af9..36e1c0fe7 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 @@ -40,8 +40,8 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent } logger.LogInformation("Completed AI application scoring job for application {ApplicationId}.", args.ApplicationId); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(creatorId); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(args.RequestedByUserId); } catch (Exception ex) { 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 b79b83ad5..17a158467 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 @@ -32,8 +32,8 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr args.ApplicationId); await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(creatorId); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(args.RequestedByUserId); } catch (Exception ex) { 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 7bc807768..34f36736f 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 @@ -51,8 +51,8 @@ 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); - var creatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(creatorId); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(args.RequestedByUserId); return; } @@ -115,8 +115,8 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent throw analysisException; } - var completedCreatorId = await AIGenerationRequestJobHelper.MarkCompletedInNewUowAndGetCreatorIdAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(completedCreatorId); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); + await aiRateLimiter.StampAsync(args.RequestedByUserId); } catch (Exception ex) { 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 a74b78655..54f45961a 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 @@ -13,6 +13,7 @@ using Volo.Abp.BackgroundJobs; using Volo.Abp.Domain.Repositories; using Volo.Abp.DistributedLocking; +using Volo.Abp.Users; using Xunit; using Xunit.Abstractions; @@ -50,6 +51,7 @@ public async Task QueueAllAIStagesAsync_Should_Enqueue_Pipeline_Job_When_None_Ex capturedArgs!.ApplicationId.ShouldBe(applicationId); capturedArgs.TenantId.ShouldBe(tenantId); capturedArgs.PromptVersion.ShouldBe("v1"); + capturedArgs.RequestedByUserId.ShouldBe(CreateQueueCurrentUserId); capturedArgs.RequestKey.ShouldBe(AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.PipelineOperationType)); await backgroundJobManager.Received(1).EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); await repository.Received(1).InsertAsync(Arg.Is(r => @@ -120,6 +122,7 @@ public async Task QueueApplicationAnalysisAsync_Should_Enqueue_New_Request_When_ capturedArgs!.ApplicationId.ShouldBe(applicationId); capturedArgs.TenantId.ShouldBe(tenantId); capturedArgs.PromptVersion.ShouldBe(promptVersion); + capturedArgs.RequestedByUserId.ShouldBe(CreateQueueCurrentUserId); capturedArgs.RequestKey.ShouldBe(AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType)); await repository.Received(1).InsertAsync(Arg.Is(r => r.ApplicationId == applicationId && @@ -186,6 +189,7 @@ public async Task QueueAttachmentSummaryAsync_Should_Enqueue_New_Request_When_No capturedArgs!.ApplicationId.ShouldBe(applicationId); capturedArgs.TenantId.ShouldBe(tenantId); capturedArgs.PromptVersion.ShouldBe(promptVersion); + capturedArgs.RequestedByUserId.ShouldBe(CreateQueueCurrentUserId); capturedArgs.RequestKey.ShouldBe(AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType)); await repository.Received(1).InsertAsync(Arg.Is(r => r.ApplicationId == applicationId && @@ -252,6 +256,7 @@ public async Task QueueApplicationScoringAsync_Should_Enqueue_New_Request_When_N capturedArgs!.ApplicationId.ShouldBe(applicationId); capturedArgs.TenantId.ShouldBe(tenantId); capturedArgs.PromptVersion.ShouldBe(promptVersion); + capturedArgs.RequestedByUserId.ShouldBe(CreateQueueCurrentUserId); capturedArgs.RequestKey.ShouldBe(AIGenerationRequestKeyHelper.BuildRequestKey(tenantId, applicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType)); await repository.Received(1).InsertAsync(Arg.Is(r => r.ApplicationId == applicationId && @@ -310,6 +315,16 @@ private static ApplicationAIGenerationQueue CreateQueue( repository, new TestDistributedLockProvider(), rateLimiter, + CreateCurrentUser(), Substitute.For>()); } + + private static readonly Guid CreateQueueCurrentUserId = Guid.NewGuid(); + + private static ICurrentUser CreateCurrentUser() + { + var currentUser = Substitute.For(); + currentUser.Id.Returns(CreateQueueCurrentUserId); + return currentUser; + } } 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 fecaae260..bc24754ba 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 @@ -33,11 +33,13 @@ public async Task ExecuteAsync_Should_Mark_Request_Completed_When_Features_Disab requests.Add(request); var job = BuildJob(featureChecker, repository); + var requestedByUserId = Guid.NewGuid(); await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs { ApplicationId = request.ApplicationId!.Value, RequestKey = request.RequestKey, + RequestedByUserId = requestedByUserId, TenantId = request.TenantId }); @@ -61,21 +63,26 @@ public async Task ExecuteAsync_Should_Publish_Event_When_Pipeline_Scoring_Comple .Returns(new ApplicationScoringResultDto { Completed = true }); var localEventBus = Substitute.For(); + var rateLimiter = Substitute.For(); + var requestedByUserId = Guid.NewGuid(); var job = BuildJob( featureChecker, repository, localEventBus: localEventBus, + rateLimiter: rateLimiter, applicationScoringAppService: scoringAppService); await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs { ApplicationId = request.ApplicationId!.Value, RequestKey = request.RequestKey, + RequestedByUserId = requestedByUserId, TenantId = request.TenantId }); request.Status.ShouldBe(AIGenerationRequestStatus.Completed); + await rateLimiter.Received(1).StampAsync(requestedByUserId); await localEventBus.Received(1).PublishAsync( Arg.Is(x => x.ApplicationId == request.ApplicationId)); } @@ -86,7 +93,8 @@ private RunApplicationAIPipelineJob BuildJob( ILocalEventBus? localEventBus = null, IAttachmentSummaryAppService? attachmentSummaryAppService = null, IApplicationAnalysisAppService? applicationAnalysisAppService = null, - IApplicationScoringAppService? applicationScoringAppService = null) + IApplicationScoringAppService? applicationScoringAppService = null, + IAIRateLimiter? rateLimiter = null) { return new RunApplicationAIPipelineJob( Substitute.For(), @@ -98,7 +106,7 @@ private RunApplicationAIPipelineJob BuildJob( generationRequestRepository, Substitute.For(), GetRequiredService(), - Substitute.For(), + rateLimiter ?? Substitute.For(), NullLogger.Instance); } From d80943c3bdc57ff9e8816f6415eead8688135782 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 7 May 2026 08:39:27 -0700 Subject: [PATCH 07/21] AB#32290 harden AI generation cooldown --- .../RateLimit/AIRateLimiter.cs | 26 +++++++--- .../AIGenerationRequestJobHelper.cs | 23 +++++++++ .../GenerateApplicationAnalysisJob.cs | 2 +- .../GenerateApplicationScoringJob.cs | 2 +- .../GenerateAttachmentSummaryJob.cs | 2 +- .../RunApplicationAIPipelineJob.cs | 4 +- .../Pages/GrantApplications/ai-rate-limit.js | 2 +- .../Automation/AIGenerationQueueTests.cs | 51 ++++++++++++++++++- 8 files changed, 98 insertions(+), 14 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs index db3b0cc82..ab3f05ad2 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -26,8 +26,14 @@ public class AIRateLimiter( private const string LockPrefix = "AI:CooldownLock:"; private const int DefaultCooldownSeconds = 60; - private int CooldownSeconds => - configuration.GetValue("AI:RateLimit:CooldownSeconds") ?? DefaultCooldownSeconds; + private int CooldownSeconds + { + get + { + var configured = configuration.GetValue("AI:RateLimit:CooldownSeconds"); + return configured > 0 ? configured.Value : DefaultCooldownSeconds; + } + } public virtual async Task EnsureAsync() { @@ -58,7 +64,11 @@ public virtual async Task StampAsync(Guid? userId) { if (userId is Guid resolvedUserId) { - await StampAsync(resolvedUserId, CooldownSeconds); + var userLock = distributedLockProvider.CreateLock(LockPrefix + resolvedUserId); + using (await userLock.AcquireAsync()) + { + await StampAsync(resolvedUserId, CooldownSeconds); + } } } @@ -69,10 +79,14 @@ public virtual async Task GetStateAsync() return new AIRateLimitStateDto { RetryAfterSeconds = 0 }; } - return new AIRateLimitStateDto + var userLock = distributedLockProvider.CreateLock(LockPrefix + userId); + using (await userLock.AcquireAsync()) { - RetryAfterSeconds = await GetRemainingSecondsAsync(userId) - }; + return new AIRateLimitStateDto + { + RetryAfterSeconds = await GetRemainingSecondsAsync(userId) + }; + } } private async Task GetRemainingSecondsAsync(Guid userId) 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 978b3d587..59a1fb80f 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 @@ -2,6 +2,8 @@ using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; @@ -84,6 +86,27 @@ public static async Task MarkFailedInNewUowAsync( await uow.CompleteAsync(); } + public static async Task StampRateLimitBestEffortAsync( + IAIRateLimiter aiRateLimiter, + ILogger logger, + Guid? requestedByUserId, + Guid applicationId, + string requestKey) + { + try + { + await aiRateLimiter.StampAsync(requestedByUserId); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "AI rate-limit cooldown stamp failed after completed AI generation request for application {ApplicationId} and request {RequestKey}.", + applicationId, + requestKey); + } + } + public static async Task GetLatestRequestAsync( IRepository generationRequestRepository, Expression> predicate) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index 77cf24468..35d3f554c 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 @@ -32,7 +32,7 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(args.RequestedByUserId); + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, args.RequestKey); } catch (Exception ex) { 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 36e1c0fe7..2292324a5 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 @@ -41,7 +41,7 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent logger.LogInformation("Completed AI application scoring job for application {ApplicationId}.", args.ApplicationId); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(args.RequestedByUserId); + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, args.RequestKey); } catch (Exception ex) { 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 17a158467..63507cd58 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 @@ -33,7 +33,7 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(args.RequestedByUserId); + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, args.RequestKey); } catch (Exception ex) { 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 34f36736f..2a46fc353 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 @@ -52,7 +52,7 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) { logger.LogDebug("All AI features are disabled, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(args.RequestedByUserId); + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, args.RequestKey); return; } @@ -116,7 +116,7 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent } await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); - await aiRateLimiter.StampAsync(args.RequestedByUserId); + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, args.RequestKey); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js index 330cd290c..ad5a7221d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js @@ -110,6 +110,6 @@ setTimeout(fetchState, 250); }); - document.addEventListener('DOMContentLoaded', fetchState); + document.addEventListener('DOMContentLoaded', () => fetchState()); if (document.readyState !== 'loading') fetchState(); })(); 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 54f45961a..f499604bc 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 @@ -7,6 +7,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; @@ -132,6 +133,48 @@ await repository.Received(1).InsertAsync(Arg.Is(r => r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); } + [Fact] + public async Task QueueApplicationAnalysisAsync_Should_Check_Rate_Limit_Before_Enqueueing_New_Request() + { + 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())); + + var backgroundJobManager = Substitute.For(); + var rateLimiter = Substitute.For(); + rateLimiter.EnsureAsync().Returns(Task.CompletedTask); + var queue = CreateQueue(backgroundJobManager, repository, rateLimiter); + + await queue.QueueApplicationAnalysisAsync(applicationId, tenantId); + + await rateLimiter.Received(1).EnsureAsync(); + await repository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await backgroundJobManager.Received(1).EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueApplicationAnalysisAsync_Should_Not_Insert_Or_Enqueue_When_Rate_Limited() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var rateLimiter = Substitute.For(); + rateLimiter.EnsureAsync().Returns(_ => throw new InvalidOperationException("rate limited")); + var queue = CreateQueue(backgroundJobManager, repository, rateLimiter); + + await Should.ThrowAsync(() => queue.QueueApplicationAnalysisAsync(applicationId, tenantId)); + + await rateLimiter.Received(1).EnsureAsync(); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task QueueAttachmentSummaryAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() { @@ -308,8 +351,12 @@ private static ApplicationAIGenerationQueue CreateQueue( IRepository repository, Unity.AI.RateLimit.IAIRateLimiter? rateLimiter = null) { - rateLimiter ??= Substitute.For(); - rateLimiter.EnsureAsync().Returns(Task.CompletedTask); + if (rateLimiter == null) + { + rateLimiter = Substitute.For(); + rateLimiter.EnsureAsync().Returns(Task.CompletedTask); + } + return new ApplicationAIGenerationQueue( backgroundJobManager, repository, From 40249eb291ed2d56b03cd548627b80003112831c Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 30 Apr 2026 17:10:24 -0700 Subject: [PATCH 08/21] AB#32892 centralize AI feature-disabled checks --- .../AttachmentSummaryAppService.cs | 30 +++++++++++-------- .../ApplicationAnalysisAppService.cs | 13 ++++---- .../ApplicationContentAppService.cs | 20 +++++-------- .../ApplicationScoringAppService.cs | 13 ++++---- .../Settings/AIFeatureGuard.cs | 21 +++++++++++++ .../Features/AIFeatures.cs | 9 ++++++ .../Localization/AI/en.json | 5 ++++ .../Localization/AILocalizationKeys.cs | 9 ++++++ .../ApplicationContentAppServiceTests.cs | 8 ++++- 9 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIFeatureGuard.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs index 9a97ee921..6e1cedc43 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs @@ -2,11 +2,13 @@ using System.Collections.Generic; using System.Threading.Tasks; using Unity.AI; +using Unity.AI.Features; +using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.Permissions; +using Unity.AI.Settings; using Volo.Abp; using Volo.Abp.DependencyInjection; -using Volo.Abp.Features; namespace Unity.GrantManager.Attachments; @@ -14,25 +16,23 @@ namespace Unity.GrantManager.Attachments; [ExposeServices(typeof(AttachmentSummaryAppService), typeof(IAttachmentSummaryAppService))] public class AttachmentSummaryAppService( IAttachmentSummaryService attachmentSummaryService, - IFeatureChecker featureChecker) : AIAppService, IAttachmentSummaryAppService + AIFeatureGuard featureGuard) : AIAppService, IAttachmentSummaryAppService { - public async Task GenerateAttachmentSummaryAsync(System.Guid attachmentId, string? promptVersion = null) + public virtual async Task GenerateAttachmentSummaryAsync(System.Guid attachmentId, string? promptVersion = null) { - if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) - { - throw new UserFriendlyException("AI attachment summaries are not enabled."); - } + await featureGuard.EnsureEnabledAsync( + AIFeatures.AttachmentSummaries, + AILocalizationKeys.AttachmentSummariesDisabled); await attachmentSummaryService.GenerateAndSaveAsync(attachmentId, promptVersion); return new AttachmentSummaryResultDto { Completed = true }; } - public async Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null) + public virtual async Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null) { - if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) - { - throw new UserFriendlyException("AI attachment summaries are not enabled."); - } + await featureGuard.EnsureEnabledAsync( + AIFeatures.AttachmentSummaries, + AILocalizationKeys.AttachmentSummariesDisabled); if (attachmentIds.Count == 0) { @@ -54,7 +54,10 @@ public async Task> GenerateAttachmentSummariesA [RemoteService(IsEnabled = false)] public virtual async Task> GenerateAttachmentSummariesForPipelineAsync(List attachmentIds, string? promptVersion = null) { - if (attachmentIds.Count == 0) return []; + if (attachmentIds.Count == 0) + { + return []; + } var results = new List(); foreach (var attachmentId in attachmentIds) @@ -62,6 +65,7 @@ public virtual async Task> GenerateAttachmentSu await attachmentSummaryService.GenerateAndSaveAsync(attachmentId, promptVersion); results.Add(new AttachmentSummaryResultDto { Completed = true }); } + return results; } } 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 9c1f173d7..843d4a89f 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 @@ -3,9 +3,11 @@ using System.Threading.Tasks; using Unity.AI; using Unity.AI.Automation; +using Unity.AI.Features; +using Unity.AI.Localization; using Unity.AI.Permissions; +using Unity.AI.Settings; using Volo.Abp; -using Volo.Abp.Features; using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.GrantApplications; @@ -14,16 +16,15 @@ namespace Unity.GrantManager.GrantApplications; public class ApplicationAnalysisAppService( Unity.AI.Operations.IApplicationAnalysisService applicationAnalysisService, IApplicationAIGenerationQueue aiGenerationQueue, - IFeatureChecker featureChecker, + AIFeatureGuard featureGuard, ICurrentTenant currentTenant) : AIAppService, IApplicationAnalysisAppService { public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { - if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis")) - { - throw new UserFriendlyException("AI application analysis is not enabled."); - } + await featureGuard.EnsureEnabledAsync( + AIFeatures.ApplicationAnalysis, + AILocalizationKeys.ApplicationAnalysisDisabled); await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, currentTenant.Id, promptVersion); return new ApplicationAnalysisResultDto { Completed = false }; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs index d0fbe4d7d..72c01b8c1 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs @@ -3,9 +3,10 @@ using System.Threading.Tasks; using Unity.AI; using Unity.AI.Automation; +using Unity.AI.Features; +using Unity.AI.Localization; using Unity.AI.Permissions; -using Volo.Abp; -using Volo.Abp.Features; +using Unity.AI.Settings; using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.GrantApplications; @@ -15,20 +16,15 @@ namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.Analysis.ViewScoringResult)] public class ApplicationContentAppService( IApplicationAIGenerationQueue aiGenerationQueue, - IFeatureChecker featureChecker, + AIFeatureGuard featureGuard, ICurrentTenant currentTenant) : AIAppService, IApplicationContentAppService { - public async Task GenerateContentAsync(Guid applicationId, string? promptVersion = null) + public virtual async Task GenerateContentAsync(Guid applicationId, string? promptVersion = null) { - 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) - { - throw new UserFriendlyException("AI generate all is not enabled."); - } + await featureGuard.EnsureEnabledAsync(AIFeatures.AttachmentSummaries, AILocalizationKeys.GenerateAllDisabled); + await featureGuard.EnsureEnabledAsync(AIFeatures.ApplicationAnalysis, AILocalizationKeys.GenerateAllDisabled); + await featureGuard.EnsureEnabledAsync(AIFeatures.Scoring, AILocalizationKeys.GenerateAllDisabled); await aiGenerationQueue.QueueAllAIStagesAsync(applicationId, currentTenant.Id, promptVersion); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs index ff79dfef6..9c65c1d1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs @@ -2,28 +2,29 @@ using System; using System.Threading.Tasks; using Unity.AI; +using Unity.AI.Features; +using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.Permissions; +using Unity.AI.Settings; using Unity.GrantManager.GrantApplications.Automation.Events; using Volo.Abp; using Volo.Abp.EventBus.Local; -using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.Analysis.GenerateScoring)] public class ApplicationScoringAppService( IApplicationScoringService applicationScoringService, - IFeatureChecker featureChecker, + AIFeatureGuard featureGuard, ILocalEventBus localEventBus) : AIAppService, IApplicationScoringAppService { public virtual async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { - if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) - { - throw new UserFriendlyException("AI scoring is not enabled."); - } + await featureGuard.EnsureEnabledAsync( + AIFeatures.Scoring, + AILocalizationKeys.ScoringDisabled); await applicationScoringService.RegenerateAndSaveAsync(applicationId, promptVersion); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIFeatureGuard.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIFeatureGuard.cs new file mode 100644 index 000000000..2a71584b3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIFeatureGuard.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Localization; +using System.Threading.Tasks; +using Unity.AI.Localization; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; + +namespace Unity.AI.Settings; + +public class AIFeatureGuard( + IFeatureChecker featureChecker, + IStringLocalizer localizer) : ITransientDependency +{ + public async Task EnsureEnabledAsync(string featureName, string disabledMessageKey) + { + if (!await featureChecker.IsEnabledAsync(featureName)) + { + throw new UserFriendlyException(localizer[disabledMessageKey]); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs new file mode 100644 index 000000000..e658c3635 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs @@ -0,0 +1,9 @@ +namespace Unity.AI.Features; + +public static class AIFeatures +{ + public const string Reporting = "Unity.AIReporting"; + public const string AttachmentSummaries = "Unity.AI.AttachmentSummaries"; + public const string ApplicationAnalysis = "Unity.AI.ApplicationAnalysis"; + public const string Scoring = "Unity.AI.Scoring"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index f11515ca6..10035c662 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -18,6 +18,11 @@ "Setting:AI.AutomaticGenerationEnabled": "Automatically Generate AI Analysis", "Setting:AI.ManualGenerationEnabled": "Manually Initiate AI Analysis", + "AI:AttachmentSummariesDisabled": "AI attachment summaries are not enabled.", + "AI:ApplicationAnalysisDisabled": "AI application analysis is not enabled.", + "AI:ScoringDisabled": "AI scoring is not enabled.", + "AI:GenerateAllDisabled": "AI generation is not enabled.", + "AIPrompts": "AI Prompts", "AIPrompt": "AI Prompt", "AIPromptVersion": "Prompt Version", diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs new file mode 100644 index 000000000..1b099e5fe --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs @@ -0,0 +1,9 @@ +namespace Unity.AI.Localization; + +public static class AILocalizationKeys +{ + public const string AttachmentSummariesDisabled = "AI:AttachmentSummariesDisabled"; + public const string ApplicationAnalysisDisabled = "AI:ApplicationAnalysisDisabled"; + public const string ScoringDisabled = "AI:ScoringDisabled"; + public const string GenerateAllDisabled = "AI:GenerateAllDisabled"; +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationContentAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationContentAppServiceTests.cs index 366018a28..84a14dfe3 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationContentAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationContentAppServiceTests.cs @@ -1,7 +1,10 @@ +using Microsoft.Extensions.Localization; using NSubstitute; using Shouldly; using System; using System.Threading.Tasks; +using Unity.AI.Localization; +using Unity.AI.Settings; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Unity.AI.Automation; @@ -20,6 +23,8 @@ public async Task GenerateContentAsync_Should_Return_Completed_Result() featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(true); featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(true); featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); + var localizer = Substitute.For>(); + var featureGuard = new AIFeatureGuard(featureChecker, localizer); var queue = Substitute.For(); queue.QueueAllAIStagesAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -27,7 +32,7 @@ public async Task GenerateContentAsync_Should_Return_Completed_Result() var currentTenant = Substitute.For(); currentTenant.Id.Returns(Guid.NewGuid()); - var service = new ApplicationContentAppService(queue, featureChecker, currentTenant); + var service = new ApplicationContentAppService(queue, featureGuard, currentTenant); var result = await service.GenerateContentAsync(Guid.NewGuid()); @@ -36,3 +41,4 @@ public async Task GenerateContentAsync_Should_Return_Completed_Result() await queue.Received(1).QueueAllAIStagesAsync(Arg.Any(), Arg.Any(), Arg.Any()); } } + From dcc2d6efeabecddf359eeb1f82a85855fc0e66bb Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 30 Apr 2026 17:06:32 -0700 Subject: [PATCH 09/21] AB#32890 remove dead AI completion code path --- .../AI/IAIService.cs | 1 - .../AI/Requests/AICompletionRequest.cs | 16 ----------- .../AI/Responses/AICompletionResponse.cs | 10 ------- .../AI/Runtime/OpenAIRuntimeService.cs | 28 ------------------- 4 files changed, 55 deletions(-) delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AICompletionRequest.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AICompletionResponse.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 84c1e1c08..be06cec5a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -8,7 +8,6 @@ public interface IAIService { Task IsAvailableAsync(); - Task GenerateCompletionAsync(AICompletionRequest request); Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request); Task GenerateApplicationScoringAsync(ApplicationScoringRequest request); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AICompletionRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AICompletionRequest.cs deleted file mode 100644 index cc134ef98..000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AICompletionRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Unity.AI.Requests -{ - public class AICompletionRequest - { - [JsonPropertyName("userPrompt")] - public string UserPrompt { get; set; } = string.Empty; - - [JsonPropertyName("maxTokens")] - public int MaxTokens { get; set; } = 150; - - [JsonPropertyName("temperature")] - public double? Temperature { get; set; } - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AICompletionResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AICompletionResponse.cs deleted file mode 100644 index 0f0c1c8ef..000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AICompletionResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Unity.AI.Responses -{ - public class AICompletionResponse - { - [JsonPropertyName("content")] - public string Content { get; set; } = string.Empty; - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 7d7ad49e3..16353dd35 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -24,11 +24,7 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; - private const string AIServiceNotConfiguredMessage = "AI service not available - service not configured."; - private const string AIServiceTemporarilyUnavailableMessage = "AI request failed - service temporarily unavailable."; - private const string AIRequestFailedRetryMessage = "AI request failed - please try again later."; private const int MaxAiAttempts = 3; - private const int DefaultCompletionTokens = 2000; private const int DefaultAttachmentSummaryCompletionTokens = 2000; private const int DefaultApplicationAnalysisCompletionTokens = 4000; private const int DefaultApplicationScoringCompletionTokens = 8000; @@ -69,19 +65,6 @@ public Task IsAvailableAsync() return Task.FromResult(true); } - public async Task GenerateCompletionAsync(AICompletionRequest request) - { - var result = await GenerateWithRetryAsync( - () => _openAITransportService.GenerateSummaryAsync( - request?.UserPrompt ?? string.Empty, - null, - request?.MaxTokens ?? DefaultCompletionTokens, - request?.Temperature), - AIProviderPayloadValidator.IsValidAttachmentSummaryText, - "completion"); - return new AICompletionResponse { Content = ResolveNarrativeContent(result) }; - } - public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { ArgumentNullException.ThrowIfNull(request); @@ -311,17 +294,6 @@ private async Task GenerateWithRetryAsync( return lastResult; } - private static string ResolveNarrativeContent(AIOperationResult result) - { - return result.Outcome switch - { - AIOperationOutcome.Success => result.Content, - AIOperationOutcome.PermanentFailure => AIServiceNotConfiguredMessage, - AIOperationOutcome.TransientFailure => AIServiceTemporarilyUnavailableMessage, - _ => AIRequestFailedRetryMessage - }; - } - private static int? TryGetInt32(JsonElement element, string propertyName) { return element.TryGetProperty(propertyName, out var property) From e63dd949f8f93e1c2742a78b28c277d15f65ba44 Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 7 May 2026 15:22:19 -0700 Subject: [PATCH 10/21] Optimized the main GrantApplications table to only pull necessary columns/data. - Added RequestedFields to the DTO and repository - Added ApplicationListRecord - Backend joins related entities only when needed - Selecting a joined column for display will reload necessary data. Recreated post .NET10 update --- .../GrantApplicationListInputDto.cs | 7 + .../GrantApplicationAppService.cs | 202 +++++++-- .../Applications/ApplicationListRecord.cs | 126 ++++++ .../Applications/IApplicationRepository.cs | 11 + .../Repositories/ApplicationRepository.cs | 345 +++++++++++++- .../Pages/GrantApplications/Index.js | 77 +++- .../ApplicationListRecordAlignmentTests.cs | 421 ++++++++++++++++++ .../GrantApplicationListTests.cs | 296 ++++++++++++ 8 files changed, 1433 insertions(+), 52 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationListRecord.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/ApplicationListRecordAlignmentTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationListTests.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationListInputDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationListInputDto.cs index 7de297c31..88c0e3799 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationListInputDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationListInputDto.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Volo.Abp.Application.Dtos; namespace Unity.GrantManager.GrantApplications @@ -7,5 +8,11 @@ public class GrantApplicationListInputDto : PagedAndSortedResultRequestDto { public DateTime? SubmittedFromDate { get; set; } public DateTime? SubmittedToDate { get; set; } + /// + /// Column names that are currently visible in the UI. When provided, only + /// the database JOINs required to populate those columns are executed. + /// Pass null or an empty list to load everything (backward-compatible). + /// + public List? RequestedFields { get; set; } } } 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 389934299..c7d037855 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -22,6 +22,7 @@ using Unity.GrantManager.Events; using Unity.GrantManager.Flex; using Unity.GrantManager.Identity; +using Unity.GrantManager.GlobalTag; using Unity.GrantManager.Payments; using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; @@ -72,56 +73,177 @@ public class GrantApplicationAppService( public async Task> GetListAsync(GrantApplicationListInputDto input) { - // 1. Fetch applications with filters + paging in DB - var applications = await applicationRepository.WithFullDetailsAsync( + var listRecords = await applicationRepository.GetApplicationListRecordsAsync( input.SkipCount, input.MaxResultCount, input.Sorting, input.SubmittedFromDate, - input.SubmittedToDate + input.SubmittedToDate, + requestedFields: input.RequestedFields ); - var applicationIds = applications.Select(a => a.Id).ToList(); + var applicationIds = listRecords.Select(r => r.Id).ToList(); - // 2. Fetch payment rollup batch if feature enabled - bool paymentsFeatureEnabled = await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); + + // Fetch payment rollup batch only when the feature is enabled AND at least one + // payment column is visible. The two payment column sNames are "totalPaidAmount" + // and "paymentInfo"; null RequestedFields means all columns are requested. + bool paymentColumnsRequested = input.RequestedFields == null || input.RequestedFields.Any(f => + f.Equals("totalPaidAmount", StringComparison.OrdinalIgnoreCase) + || f.Equals("paymentInfo", StringComparison.OrdinalIgnoreCase)); + + + bool paymentsFeatureEnabled = false; + if (paymentColumnsRequested) // Only even check if a column is requested. + { + paymentsFeatureEnabled = await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); + } Dictionary paymentRollupBatch = []; - if (paymentsFeatureEnabled && applicationIds.Count > 0) + if (paymentColumnsRequested && paymentsFeatureEnabled && applicationIds.Count > 0) { paymentRollupBatch = await paymentRequestService.GetApplicationPaymentRollupBatchAsync(applicationIds); } - // 3. Map applications to DTOs - var appDtos = applications.Select(app => + var appDtos = listRecords.Select(rec => { - var appDto = ObjectMapper.Map(app); - - appDto.Status = app.ApplicationStatus.InternalStatus; - appDto.Applicant = ObjectMapper.Map(app.Applicant); - appDto.Category = app.ApplicationForm.Category ?? string.Empty; - appDto.Owner = BuildApplicationOwner(app.Owner); - appDto.OrganizationName = app.Applicant?.OrgName ?? string.Empty; - appDto.ApplicationTag = ObjectMapper.Map, List>(app.ApplicationTags?.ToList() ?? []); - appDto.NonRegOrgName = app.Applicant?.NonRegOrgName ?? string.Empty; - appDto.OrganizationType = app.Applicant?.OrganizationType ?? string.Empty; - appDto.Assignees = BuildApplicationAssignees(app.ApplicationAssignments); - appDto.SubStatusDisplayValue = MapSubstatusDisplayValue(appDto.SubStatus ?? string.Empty); - appDto.DeclineRational = MapDeclineRationalDisplayValue(appDto.DeclineRational ?? string.Empty); - appDto.ContactFullName = app.ApplicantAgent?.Name; - appDto.ContactEmail = app.ApplicantAgent?.Email; - appDto.ContactTitle = app.ApplicantAgent?.Title; - appDto.ContactBusinessPhone = app.ApplicantAgent?.Phone; - appDto.ContactCellPhone = app.ApplicantAgent?.Phone2; - appDto.ApplicationLinks = ObjectMapper.Map, List>(app.ApplicationLinks?.ToList() ?? []); + var appDto = new GrantApplicationDto + { + Id = rec.Id, + ProjectName = rec.ProjectName, + ReferenceNo = rec.ReferenceNo, + RequestedAmount = rec.RequestedAmount, + TotalProjectBudget = rec.TotalProjectBudget, + EconomicRegion = rec.EconomicRegion ?? string.Empty, + City = rec.City ?? string.Empty, + ProposalDate = rec.ProposalDate ?? default, + SubmissionDate = rec.SubmissionDate, + FinalDecisionDate = rec.FinalDecisionDate, + DueDate = rec.DueDate, + NotificationDate = rec.NotificationDate, + ProjectSummary = rec.ProjectSummary ?? string.Empty, + TotalScore = rec.TotalScore ?? 0, + RecommendedAmount = rec.RecommendedAmount, + ApprovedAmount = rec.ApprovedAmount, + LikelihoodOfFunding = rec.LikelihoodOfFunding ?? string.Empty, + DueDiligenceStatus = rec.DueDiligenceStatus ?? string.Empty, + SubStatus = rec.SubStatus ?? string.Empty, + SubStatusDisplayValue = MapSubstatusDisplayValue(rec.SubStatus ?? string.Empty), + DeclineRational = MapDeclineRationalDisplayValue(rec.DeclineRational ?? string.Empty), + Notes = rec.Notes ?? string.Empty, + AssessmentResultStatus = rec.AssessmentResultStatus ?? string.Empty, + AssessmentResultDate = rec.AssessmentResultDate ?? default, + ProjectStartDate = rec.ProjectStartDate, + ProjectEndDate = rec.ProjectEndDate, + PercentageTotalProjectBudget = rec.PercentageTotalProjectBudget, + ProjectFundingTotal = rec.ProjectFundingTotal, + Community = rec.Community, + CommunityPopulation = rec.CommunityPopulation, + Acquisition = rec.Acquisition, + Forestry = rec.Forestry, + ForestryFocus = rec.ForestryFocus, + ElectoralDistrict = rec.ElectoralDistrict, + ApplicantElectoralDistrict = rec.ApplicantElectoralDistrict, + Place = rec.Place, + RegionalDistrict = rec.RegionalDistrict, + OwnerId = rec.OwnerId, + DefaultSiteId = rec.DefaultSiteId, + SigningAuthorityFullName = rec.SigningAuthorityFullName, + SigningAuthorityTitle = rec.SigningAuthorityTitle, + SigningAuthorityEmail = rec.SigningAuthorityEmail, + SigningAuthorityBusinessPhone = rec.SigningAuthorityBusinessPhone, + SigningAuthorityCellPhone = rec.SigningAuthorityCellPhone, + ContractNumber = rec.ContractNumber, + ContractExecutionDate = rec.ContractExecutionDate, + RiskRanking = rec.RiskRanking, + UnityApplicationId = rec.UnityApplicationId, + + // From ApplicationStatus + Status = rec.Status, + + // From ApplicationForm + Category = rec.Category, + + // From Applicant — both the nested DTO and the flattened top-level properties + Applicant = new GrantApplicationApplicantDto + { + Id = rec.ApplicantId, + ApplicantName = rec.ApplicantName ?? string.Empty, + SupplierId = rec.ApplicantSupplierId ?? Guid.Empty, + Sector = rec.ApplicantSector ?? string.Empty, + SubSector = rec.ApplicantSubSector ?? string.Empty, + OrgNumber = rec.ApplicantOrgNumber ?? string.Empty, + OrgName = rec.ApplicantOrgName ?? string.Empty, + OrgStatus = rec.ApplicantOrgStatus ?? string.Empty, + BusinessNumber = rec.ApplicantBusinessNumber ?? string.Empty, + OrganizationType = rec.ApplicantOrganizationType ?? string.Empty, + OrganizationSize = rec.ApplicantOrganizationSize ?? string.Empty, + SectorSubSectorIndustryDesc = rec.ApplicantSectorSubSectorIndustryDesc ?? string.Empty, + RedStop = rec.ApplicantRedStop ?? false, + IndigenousOrgInd = rec.ApplicantIndigenousOrgInd ?? string.Empty, + UnityApplicantId = rec.ApplicantUnityApplicantId ?? string.Empty, + FiscalDay = rec.ApplicantFiscalDay?.ToString() ?? string.Empty, + FiscalMonth = rec.ApplicantFiscalMonth ?? string.Empty + }, + OrganizationName = rec.ApplicantOrgName ?? string.Empty, + NonRegOrgName = rec.ApplicantNonRegOrgName ?? string.Empty, + OrganizationType = rec.ApplicantOrganizationType ?? string.Empty, + OrgStatus = rec.ApplicantOrgStatus, + BusinessNumber = rec.ApplicantBusinessNumber, + OrgNumber = rec.ApplicantOrgNumber, + OrganizationSize = rec.ApplicantOrganizationSize, + SectorSubSectorIndustryDesc = rec.ApplicantSectorSubSectorIndustryDesc, + Sector = rec.ApplicantSector, + SubSector = rec.ApplicantSubSector, + + // From ApplicantAgent + ContactFullName = rec.ContactFullName, + ContactTitle = rec.ContactTitle, + ContactEmail = rec.ContactEmail, + ContactBusinessPhone = rec.ContactBusinessPhone, + ContactCellPhone = rec.ContactCellPhone, + + // From Owner + Owner = rec.OwnerPersonId.HasValue + ? new GrantApplicationAssigneeDto { Id = rec.OwnerPersonId.Value, FullName = rec.OwnerFullName ?? string.Empty } + : new GrantApplicationAssigneeDto(), + + // Collections + ApplicationTag = rec.Tags + .Select(t => new ApplicationTagsDto + { + Id = t.Id, + ApplicationId = t.ApplicationId, + Tag = t.TagName != null ? new TagDto { Name = t.TagName } : null + }).ToList(), + + Assignees = rec.Assignments + .Select(a => new GrantApplicationAssigneeDto + { + Id = a.Id, + ApplicationId = a.ApplicationId, + AssigneeId = a.AssigneeId, + FullName = a.AssigneeName, + Duty = a.Duty + }).ToList(), + + ApplicationLinks = rec.Links + .Select(l => new ApplicationLinksDto + { + Id = l.Id, + ApplicationId = l.ApplicationId, + LinkedApplicationId = l.LinkedApplicationId, + LinkType = l.LinkType + }).ToList() + }; if (paymentsFeatureEnabled && paymentRollupBatch.Count > 0) { - paymentRollupBatch.TryGetValue(app.Id, out var rollup); + paymentRollupBatch.TryGetValue(rec.Id, out var rollup); appDto.PaymentInfo = new PaymentInfoDto { - ApprovedAmount = app.ApprovedAmount, + ApprovedAmount = rec.ApprovedAmount, TotalPaid = rollup?.TotalPaid ?? 0 }; } @@ -129,11 +251,15 @@ public async Task> GetListAsync(GrantApplica }).ToList(); - // 4. Get total count using same filters - var totalCount = await applicationRepository.GetCountAsync( - input.SubmittedFromDate, - input.SubmittedToDate - ); +#pragma warning disable S125 + //Code is temporarily commented out as this will be the way to get the accurate count + //once the core GrantApplications data table is moved server side from client side. + //Until then, since it is client side and always requests all records at once to be + //loaded, an extra round-trip to the database for a query is uncessary. + + //var totalCount = await applicationRepository.GetCountAsync(input.SubmittedFromDate,input.SubmittedToDate); +#pragma warning restore S125 + var totalCount = appDtos.Count; return new PagedResultDto(totalCount, appDtos); } @@ -961,7 +1087,7 @@ public async Task GetApplicationStatusAsync(Guid id) return form.AccountCodingId; } - + public async Task IsApplicantRedStopAsync(Guid applicationId) { @@ -988,7 +1114,7 @@ public async Task> GetActions(Guid applicati // NOTE: Authorization is applied on the AppService layer and is false by default // AUTHORIZATION HANDLING - bool isRedStop = application.Applicant != null && application.Applicant.RedStop == true; + bool isRedStop = application.Applicant != null && application.Applicant.RedStop == true; foreach (var item in actionDtos) { item.IsPermitted = !isRedStop && item.IsPermitted && (await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(item.ApplicationAction))); @@ -1298,4 +1424,4 @@ private static void UpdateFindingDismissedState(IEnumerable +/// Flattened projection returned by +/// . +/// Only the columns required for the application list view are selected +/// +public class ApplicationListRecord +{ + public Guid Id { get; init; } + public string ProjectName { get; init; } = string.Empty; + public string ReferenceNo { get; init; } = string.Empty; + public decimal RequestedAmount { get; init; } + public decimal TotalProjectBudget { get; init; } + public string? EconomicRegion { get; init; } + public string? City { get; init; } + public DateTime? ProposalDate { get; init; } + public DateTime SubmissionDate { get; init; } + public DateTime? FinalDecisionDate { get; init; } + public DateTime? DueDate { get; init; } + public DateTime? NotificationDate { get; init; } + public string? ProjectSummary { get; init; } + public int? TotalScore { get; init; } + public decimal RecommendedAmount { get; init; } + public decimal ApprovedAmount { get; init; } + public string? LikelihoodOfFunding { get; init; } + public string? DueDiligenceStatus { get; init; } + public string? SubStatus { get; init; } + public string? DeclineRational { get; init; } + public string? Notes { get; init; } + public string? AssessmentResultStatus { get; init; } + public DateTime? AssessmentResultDate { get; init; } + public DateTime? ProjectStartDate { get; init; } + public DateTime? ProjectEndDate { get; init; } + public double? PercentageTotalProjectBudget { get; init; } + public decimal? ProjectFundingTotal { get; init; } + public string? Community { get; init; } + public int? CommunityPopulation { get; init; } + public string? Acquisition { get; init; } + public string? Forestry { get; init; } + public string? ForestryFocus { get; init; } + public string? ElectoralDistrict { get; init; } + public string? ApplicantElectoralDistrict { get; init; } + public string? Place { get; init; } + public string? RegionalDistrict { get; init; } + public Guid? OwnerId { get; init; } + public Guid? DefaultSiteId { get; init; } + public string? SigningAuthorityFullName { get; init; } + public string? SigningAuthorityTitle { get; init; } + public string? SigningAuthorityEmail { get; init; } + public string? SigningAuthorityBusinessPhone { get; init; } + public string? SigningAuthorityCellPhone { get; init; } + public string? ContractNumber { get; init; } + public DateTime? ContractExecutionDate { get; init; } + public string? RiskRanking { get; init; } + public string? UnityApplicationId { get; init; } + + // ApplicationStatus (always joined) + public string Status { get; init; } = string.Empty; + + // ApplicationForm (always joined) + public string Category { get; init; } = string.Empty; + + // Applicant (always joined) + public Guid ApplicantId { get; init; } + public string? ApplicantName { get; init; } + public Guid? ApplicantSupplierId { get; init; } + public string? ApplicantSector { get; init; } + public string? ApplicantSubSector { get; init; } + public string? ApplicantOrgName { get; init; } + public string? ApplicantNonRegOrgName { get; init; } + public string? ApplicantOrganizationType { get; init; } + public string? ApplicantOrgNumber { get; init; } + public string? ApplicantOrgStatus { get; init; } + public string? ApplicantBusinessNumber { get; init; } + public string? ApplicantOrganizationSize { get; init; } + public string? ApplicantSectorSubSectorIndustryDesc { get; init; } + public bool? ApplicantRedStop { get; init; } + public string? ApplicantIndigenousOrgInd { get; init; } + public int? ApplicantFiscalDay { get; init; } + public string? ApplicantFiscalMonth { get; init; } + public string? ApplicantUnityApplicantId { get; init; } + + // ApplicantAgent (left-joined when present) + public string? ContactFullName { get; init; } + public string? ContactTitle { get; init; } + public string? ContactEmail { get; init; } + public string? ContactBusinessPhone { get; init; } + public string? ContactCellPhone { get; init; } + + // Owner / Person (left-joined when present) + public Guid? OwnerPersonId { get; init; } + public string? OwnerFullName { get; init; } + + // Collections (correlated subqueries) + public List Tags { get; init; } = []; + public List Assignments { get; init; } = []; + public List Links { get; init; } = []; +} + +public class ApplicationTagListItem +{ + public Guid Id { get; init; } + public Guid ApplicationId { get; init; } + public string? TagName { get; init; } +} + +public class ApplicationAssignmentListItem +{ + public Guid Id { get; init; } + public Guid ApplicationId { get; init; } + public Guid AssigneeId { get; init; } + public string AssigneeName { get; init; } = string.Empty; + public string? Duty { get; init; } +} + +public class ApplicationLinkListItem +{ + public Guid Id { get; init; } + public Guid ApplicationId { get; init; } + public Guid LinkedApplicationId { get; init; } + public ApplicationLinkType LinkType { get; init; } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs index e19610e96..62a57ce6d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs @@ -27,6 +27,17 @@ Task> WithFullDetailsAsync( string? searchTerm = null // optional search filter ); + // Optimized projected list for the application list table. + Task> GetApplicationListRecordsAsync( + int skipCount, + int maxResultCount, + string? sorting = null, + DateTime? submittedFromDate = null, + DateTime? submittedToDate = null, + string? searchTerm = null, + IReadOnlyList? requestedFields = null + ); + // Get applications by applicant ID Task> GetByApplicantIdAsync(Guid applicantId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs index a798964c6..4133f618b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs @@ -64,13 +64,38 @@ private static (DateTime? FromUtc, DateTime? ToUtc) ConvertToUtcRange( return (fromUtc, toUtc); } - /// - /// Base query with all required includes - /// + private static readonly HashSet ApplicantAgentFields = new(StringComparer.OrdinalIgnoreCase) + { + "contactFullName", "contactTitle", "contactEmail", + "contactBusinessPhone", "contactCellPhone" + }; + + private static readonly HashSet TagFields = new(StringComparer.OrdinalIgnoreCase) + { + "applicationTag" + }; + + private static readonly HashSet OwnerFields = new(StringComparer.OrdinalIgnoreCase) + { + "Owner" + }; + + private static readonly HashSet ApplicationLinkFields = new(StringComparer.OrdinalIgnoreCase) + { + "applicationLinks" + }; + + private static readonly HashSet AssignmentFields = new(StringComparer.OrdinalIgnoreCase) + { + "assignees" + }; + + private async Task> BuildBaseQueryAsync() { return (await GetQueryableAsync()) .AsNoTracking() + .AsSplitQuery() .Include(a => a.ApplicationForm) .Include(a => a.ApplicationStatus) .Include(a => a.Applicant) @@ -141,7 +166,8 @@ public async Task GetCountAsync( DateTime? submittedFromDate, DateTime? submittedToDate) { - var query = await BuildBaseQueryAsync(); + // Dont use full query, run basic to just get count. + var query = (await GetQueryableAsync()).AsNoTracking(); var (fromUtc, toUtc) = ConvertToUtcRange( submittedFromDate, submittedToDate); @@ -197,6 +223,317 @@ public async Task> GetByApplicantIdAsync(Guid applicantId) .ToListAsync(); } + public async Task> GetApplicationListRecordsAsync( + int skipCount, + int maxResultCount, + string? sorting = null, + DateTime? submittedFromDate = null, + DateTime? submittedToDate = null, + string? searchTerm = null, + IReadOnlyList? requestedFields = null) + { + var fields = requestedFields != null + ? new HashSet(requestedFields, StringComparer.OrdinalIgnoreCase) + : null; // null = all fields included + + bool includeTags = fields == null || fields.Overlaps(TagFields); + bool includeAssignees = fields == null || fields.Overlaps(AssignmentFields); + bool includeLinks = fields == null || fields.Overlaps(ApplicationLinkFields); + bool includeApplicantAgent = fields == null || fields.Overlaps(ApplicantAgentFields); + bool includeOwner = fields == null || fields.Overlaps(OwnerFields); + + // Sorting is omitted: the DataTable operates in client-side mode and re-sorts locally. + // skipCount/maxResultCount are not applied while the DataTable is in client-side mode. + var query = (await GetQueryableAsync()).AsNoTracking(); + + var (fromUtc, toUtc) = ConvertToUtcRange(submittedFromDate, submittedToDate); + if (fromUtc.HasValue) + { + query = query.Where(a => a.SubmissionDate >= fromUtc.Value); + } + + if (toUtc.HasValue) + { + query = query.Where(a => a.SubmissionDate <= toUtc.Value); + } + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + query = query.Where(a => + a.ProjectName.Contains(searchTerm) || + a.ReferenceNo.Contains(searchTerm)); + } + + // Base query — always-required navigations only (ApplicationStatus, ApplicationForm, Applicant). + var baseData = await query + .Select(a => new + { + a.Id, + a.ProjectName, + a.ReferenceNo, + a.RequestedAmount, + a.TotalProjectBudget, + a.EconomicRegion, + a.City, + a.ProposalDate, + a.SubmissionDate, + a.FinalDecisionDate, + a.DueDate, + a.NotificationDate, + a.ProjectSummary, + a.TotalScore, + a.RecommendedAmount, + a.ApprovedAmount, + a.LikelihoodOfFunding, + a.DueDiligenceStatus, + a.SubStatus, + a.DeclineRational, + a.Notes, + a.AssessmentResultStatus, + a.AssessmentResultDate, + a.ProjectStartDate, + a.ProjectEndDate, + a.PercentageTotalProjectBudget, + a.ProjectFundingTotal, + a.Community, + a.CommunityPopulation, + a.Acquisition, + a.Forestry, + a.ForestryFocus, + a.ElectoralDistrict, + a.ApplicantElectoralDistrict, + a.Place, + a.RegionalDistrict, + a.OwnerId, + a.DefaultSiteId, + a.SigningAuthorityFullName, + a.SigningAuthorityTitle, + a.SigningAuthorityEmail, + a.SigningAuthorityBusinessPhone, + a.SigningAuthorityCellPhone, + a.ContractNumber, + a.ContractExecutionDate, + a.RiskRanking, + a.UnityApplicationId, + Status = a.ApplicationStatus.InternalStatus, // ApplicationStatus (always joined) + Category = a.ApplicationForm.Category ?? string.Empty, // ApplicationForm (always joined) + a.ApplicantId, + ApplicantName = a.Applicant.ApplicantName, // Applicant (always joined) + ApplicantSupplierId = a.Applicant.SupplierId, + ApplicantSector = a.Applicant.Sector, + ApplicantSubSector = a.Applicant.SubSector, + ApplicantOrgName = a.Applicant.OrgName, + ApplicantNonRegOrgName = a.Applicant.NonRegOrgName, + ApplicantOrganizationType = a.Applicant.OrganizationType, + ApplicantOrgNumber = a.Applicant.OrgNumber, + ApplicantOrgStatus = a.Applicant.OrgStatus, + ApplicantBusinessNumber = a.Applicant.BusinessNumber, + ApplicantOrganizationSize = a.Applicant.OrganizationSize, + ApplicantSectorSubSectorIndustryDesc = a.Applicant.SectorSubSectorIndustryDesc, + ApplicantRedStop = a.Applicant.RedStop, + ApplicantIndigenousOrgInd = a.Applicant.IndigenousOrgInd, + ApplicantFiscalDay = a.Applicant.FiscalDay, + ApplicantFiscalMonth = a.Applicant.FiscalMonth, + ApplicantUnityApplicantId = a.Applicant.UnityApplicantId, + }) + .ToListAsync(); + + if (baseData.Count == 0) + { + return []; + } + + // Conditionally join ApplicantAgent + var agentMap = new Dictionary(); + if (includeApplicantAgent) + { + var agentData = await query + .Where(a => a.ApplicantAgent != null) + .Select(a => new + { + a.Id, + a.ApplicantAgent!.Name, + a.ApplicantAgent.Title, + a.ApplicantAgent.Email, + Phone = a.ApplicantAgent.Phone, + Phone2 = a.ApplicantAgent.Phone2, + }) + .ToListAsync(); + + agentMap = agentData.ToDictionary( + x => x.Id, + x => ((string?)x.Name, x.Title, x.Email, x.Phone, x.Phone2)); + } + + // Conditionally join Owner + var ownerMap = new Dictionary(); + if (includeOwner) + { + var ownerData = await query + .Where(a => a.Owner != null) + .Select(a => new { a.Id, OwnerId = a.Owner!.Id, OwnerFullName = a.Owner.FullName }) + .ToListAsync(); + + ownerMap = ownerData.ToDictionary( + x => x.Id, + x => (x.OwnerId, (string?)x.OwnerFullName)); + } + + var dbContext = await GetDbContextAsync(); + + // matchingIds is kept as IQueryable so EF Core translates it as a SQL subquery + // (WHERE ApplicationId IN (SELECT Id FROM Applications WHERE ...)) rather than + // binding thousands of individual GUID parameters. + var matchingIds = query.Select(a => a.Id); + + // Conditionally join Tags + var tagsLookup = Enumerable.Empty().ToLookup(t => Guid.Empty); + if (includeTags) + { + var tags = await dbContext.Set() + .AsNoTracking() + .Where(t => matchingIds.Contains(t.ApplicationId)) + .Select(t => new ApplicationTagListItem + { + Id = t.Id, + ApplicationId = t.ApplicationId, + TagName = t.Tag != null ? t.Tag.Name : null, + }) + .ToListAsync(); + + tagsLookup = tags.ToLookup(t => t.ApplicationId); + } + + // Conditionally join Assignments + var assignmentsLookup = Enumerable.Empty().ToLookup(aa => Guid.Empty); + if (includeAssignees) + { + var assignments = await dbContext.Set() + .AsNoTracking() + .Where(aa => matchingIds.Contains(aa.ApplicationId)) + .Select(aa => new ApplicationAssignmentListItem + { + Id = aa.Id, + ApplicationId = aa.ApplicationId, + AssigneeId = aa.AssigneeId, + AssigneeName = aa.Assignee != null ? aa.Assignee.FullName : string.Empty, + Duty = aa.Duty, + }) + .ToListAsync(); + + assignmentsLookup = assignments.ToLookup(aa => aa.ApplicationId); + } + + // Conditionally join Links + var linksLookup = Enumerable.Empty().ToLookup(l => Guid.Empty); + if (includeLinks) + { + var links = await dbContext.Set() + .AsNoTracking() + .Where(l => matchingIds.Contains(l.ApplicationId)) + .Select(l => new ApplicationLinkListItem + { + Id = l.Id, + ApplicationId = l.ApplicationId, + LinkedApplicationId = l.LinkedApplicationId, + LinkType = l.LinkType, + }) + .ToListAsync(); + + linksLookup = links.ToLookup(l => l.ApplicationId); + } + + return baseData + .Select(a => + { + agentMap.TryGetValue(a.Id, out var agent); + ownerMap.TryGetValue(a.Id, out var owner); + var hasOwner = includeOwner && ownerMap.ContainsKey(a.Id); + + return new ApplicationListRecord + { + Id = a.Id, + ProjectName = a.ProjectName, + ReferenceNo = a.ReferenceNo, + RequestedAmount = a.RequestedAmount, + TotalProjectBudget = a.TotalProjectBudget, + EconomicRegion = a.EconomicRegion, + City = a.City, + ProposalDate = a.ProposalDate, + SubmissionDate = a.SubmissionDate, + FinalDecisionDate = a.FinalDecisionDate, + DueDate = a.DueDate, + NotificationDate = a.NotificationDate, + ProjectSummary = a.ProjectSummary, + TotalScore = a.TotalScore, + RecommendedAmount = a.RecommendedAmount, + ApprovedAmount = a.ApprovedAmount, + LikelihoodOfFunding = a.LikelihoodOfFunding, + DueDiligenceStatus = a.DueDiligenceStatus, + SubStatus = a.SubStatus, + DeclineRational = a.DeclineRational, + Notes = a.Notes, + AssessmentResultStatus = a.AssessmentResultStatus, + AssessmentResultDate = a.AssessmentResultDate, + ProjectStartDate = a.ProjectStartDate, + ProjectEndDate = a.ProjectEndDate, + PercentageTotalProjectBudget = a.PercentageTotalProjectBudget, + ProjectFundingTotal = a.ProjectFundingTotal, + Community = a.Community, + CommunityPopulation = a.CommunityPopulation, + Acquisition = a.Acquisition, + Forestry = a.Forestry, + ForestryFocus = a.ForestryFocus, + ElectoralDistrict = a.ElectoralDistrict, + ApplicantElectoralDistrict = a.ApplicantElectoralDistrict, + Place = a.Place, + RegionalDistrict = a.RegionalDistrict, + OwnerId = a.OwnerId, + DefaultSiteId = a.DefaultSiteId, + SigningAuthorityFullName = a.SigningAuthorityFullName, + SigningAuthorityTitle = a.SigningAuthorityTitle, + SigningAuthorityEmail = a.SigningAuthorityEmail, + SigningAuthorityBusinessPhone = a.SigningAuthorityBusinessPhone, + SigningAuthorityCellPhone = a.SigningAuthorityCellPhone, + ContractNumber = a.ContractNumber, + ContractExecutionDate = a.ContractExecutionDate, + RiskRanking = a.RiskRanking, + UnityApplicationId = a.UnityApplicationId, + Status = a.Status, + Category = a.Category, + ApplicantId = a.ApplicantId, + ApplicantName = a.ApplicantName, + ApplicantSupplierId = a.ApplicantSupplierId, + ApplicantSector = a.ApplicantSector, + ApplicantSubSector = a.ApplicantSubSector, + ApplicantOrgName = a.ApplicantOrgName, + ApplicantNonRegOrgName = a.ApplicantNonRegOrgName, + ApplicantOrganizationType = a.ApplicantOrganizationType, + ApplicantOrgNumber = a.ApplicantOrgNumber, + ApplicantOrgStatus = a.ApplicantOrgStatus, + ApplicantBusinessNumber = a.ApplicantBusinessNumber, + ApplicantOrganizationSize = a.ApplicantOrganizationSize, + ApplicantSectorSubSectorIndustryDesc = a.ApplicantSectorSubSectorIndustryDesc, + ApplicantRedStop = a.ApplicantRedStop, + ApplicantIndigenousOrgInd = a.ApplicantIndigenousOrgInd, + ApplicantFiscalDay = a.ApplicantFiscalDay, + ApplicantFiscalMonth = a.ApplicantFiscalMonth, + ApplicantUnityApplicantId = a.ApplicantUnityApplicantId, + ContactFullName = includeApplicantAgent ? agent.Name : null, + ContactTitle = includeApplicantAgent ? agent.Title : null, + ContactEmail = includeApplicantAgent ? agent.Email : null, + ContactBusinessPhone = includeApplicantAgent ? agent.Phone : null, + ContactCellPhone = includeApplicantAgent ? agent.Phone2 : null, + OwnerPersonId = hasOwner ? owner.PersonId : (Guid?)null, + OwnerFullName = hasOwner ? owner.FullName : null, + Tags = includeTags ? tagsLookup[a.Id].ToList() : [], + Assignments = includeAssignees ? assignmentsLookup[a.Id].ToList() : [], + Links = includeLinks ? linksLookup[a.Id].ToList() : [], + }; + }) + .ToList(); + } + public async Task> GetApplicationsBySiteIdAsync(Guid siteId) { return await (await GetQueryableAsync()) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index e039b477e..0c752256c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -381,6 +381,8 @@ $(function () { function initializeDataTableAndEvents() { let initialLoad = true; + let isRestoringState = false; + let refreshDataTimeout = null; dataTable = initializeDataTable({ dt, defaultVisibleColumns, @@ -392,9 +394,32 @@ $(function () { }, dataEndpoint: unity.grantManager.grantApplications.grantApplication.getList, data: function () { + let requestedFields; + if (dataTable) { + try { + const cols = dataTable.settings()[0].aoColumns; + requestedFields = cols + .filter(function (col, idx) { return dataTable.column(idx).visible(); }) + .map(function (col) { return col.sName; }) + .filter(function (name) { return !!name; }); + if (requestedFields.length > 0) { + localStorage.setItem('GrantApplications_RequestedFields', JSON.stringify(requestedFields)); + } + } catch { /* DataTable not yet fully initialised */ } + } + if (!requestedFields || requestedFields.length === 0) { + try { + const saved = localStorage.getItem('GrantApplications_RequestedFields'); + if (saved) requestedFields = JSON.parse(saved); + } catch { } + } + if (!requestedFields || requestedFields.length === 0) { + requestedFields = defaultVisibleColumns; + } return { submittedFromDate: grantTableFilters.submittedFromDate, - submittedToDate: grantTableFilters.submittedToDate + submittedToDate: grantTableFilters.submittedToDate, + requestedFields: requestedFields }; }, responseCallback, @@ -415,20 +440,22 @@ $(function () { }; }, onStateLoadParams: function (settings, data) { - if (!initialLoad && data?.customFilters) { - // If there is any date change, this will refresh post load - // to ensure the correct data is shown based on the saved filters. - data.refreshTableWithDates = - data.customFilters.quickDateRange !== UIElements.quickDateRange.val() - || data.customFilters.submittedFromDate !== UIElements.submittedFromInput.val() - || data.customFilters.submittedToDate !== UIElements.submittedToInput.val(); - restoreCustomFilters(data.customFilters); + if (!initialLoad) { + // Mark that a state restore is in progress so column-visibility.dt + // events during restore don't trigger premature intermediate reloads. + isRestoringState = true; + if (data?.customFilters) { + restoreCustomFilters(data.customFilters); + } } }, onStateLoaded: function (dtApi, data) { // This needs to only reload when clicking on the load state not on initial page load // Otherwise it duplicates the data - if (!initialLoad && data?.refreshTableWithDates) { + if (!initialLoad) { + // All column-visibility changes are applied by this point. + // Clear the flag and fire a single reload with the correct column set. + isRestoringState = false; dtApi.ajax.reload(null, false); } initialLoad = false; // Reset flag after use @@ -460,6 +487,27 @@ $(function () { }); } }); + + dataTable.on('column-visibility.dt', function (e, settings, columnIdx) { + try { + const cols = dataTable.settings()[0].aoColumns; + const visibleFields = cols + .filter(function (col, idx) { return dataTable.column(idx).visible(); }) + .map(function (col) { return col.sName; }) + .filter(function (name) { return !!name; }); + if (visibleFields.length > 0) { + localStorage.setItem('GrantApplications_RequestedFields', JSON.stringify(visibleFields)); + } + // Only debounce on manual. During a saved-view restore, isRestoringState + // is true and onStateLoaded fires a single authoritative reload after all columns are applied + if (!isRestoringState && cols[columnIdx]?.refreshData) { + clearTimeout(refreshDataTimeout); + refreshDataTimeout = setTimeout(function () { + dataTable.ajax.reload(null, false); + }, 300); + } + } catch { } + }); } $('#search').on('input', function () { @@ -702,6 +750,7 @@ $(function () { data: 'assignees', name: 'assignees', className: 'dt-editable', + refreshData: true, render: function (data, type, row) { let displayText = ' '; @@ -1056,6 +1105,7 @@ $(function () { name: 'applicationTag', data: 'applicationTag', className: '', + refreshData: true, render: function (data) { let tagNames = data @@ -1132,6 +1182,7 @@ $(function () { name: 'Owner', data: 'owner', className: 'data-table-header', + refreshData: true, render: function (data) { return data != null ? data.fullName : ''; }, @@ -1238,6 +1289,7 @@ $(function () { name: 'applicationLinks', data: 'applicationLinks', className: 'data-table-header', + refreshData: true, render: function (data) { const linkNames = Array.from(new Set((data || []) .filter(x => x?.linkType) @@ -1286,6 +1338,7 @@ $(function () { name: 'contactFullName', data: 'contactFullName', className: 'data-table-header', + refreshData: true, render: function (data) { return data ?? ''; }, @@ -1298,6 +1351,7 @@ $(function () { name: 'contactTitle', data: 'contactTitle', className: 'data-table-header', + refreshData: true, render: function (data) { return data ?? ''; }, @@ -1310,6 +1364,7 @@ $(function () { name: 'contactEmail', data: 'contactEmail', className: 'data-table-header', + refreshData: true, render: function (data) { return data ?? ''; }, @@ -1322,6 +1377,7 @@ $(function () { name: 'contactBusinessPhone', data: 'contactBusinessPhone', className: 'data-table-header', + refreshData: true, render: function (data) { return data ?? ''; }, @@ -1334,6 +1390,7 @@ $(function () { name: 'contactCellPhone', data: 'contactCellPhone', className: 'data-table-header', + refreshData: true, render: function (data) { return data ?? ''; }, diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/ApplicationListRecordAlignmentTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/ApplicationListRecordAlignmentTests.cs new file mode 100644 index 000000000..6229d9ef0 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/ApplicationListRecordAlignmentTests.cs @@ -0,0 +1,421 @@ +using Shouldly; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Unity.GrantManager.Applications; + +using Volo.Abp.Uow; + +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.GrantApplications; + +/// +/// Verifies that returns +/// field values consistent with the full-entity data returned by +/// for the same filter inputs. +/// +public class ApplicationListRecordAlignmentTests : GrantManagerApplicationTestBase +{ + private readonly IApplicationRepository _applicationRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public ApplicationListRecordAlignmentTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + _applicationRepository = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithNullRequestedFields_ReturnsSameCount_As_WithFullDetailsAsync() + { + using var uow = _unitOfWorkManager.Begin(); + + var fullDetails = await _applicationRepository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: int.MaxValue); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + listRecords.Count.ShouldBe(fullDetails.Count); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_ScalarFields_Match_WithFullDetailsAsync() + { + using var uow = _unitOfWorkManager.Begin(); + + var fullDetails = await _applicationRepository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: int.MaxValue); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + fullDetails.ShouldNotBeEmpty(); + + foreach (var app in fullDetails) + { + var rec = listRecords.FirstOrDefault(r => r.Id == app.Id); + rec.ShouldNotBeNull($"No ApplicationListRecord found for Application Id={app.Id}"); + + rec.ProjectName.ShouldBe(app.ProjectName, $"ProjectName mismatch for Id={app.Id}"); + rec.ReferenceNo.ShouldBe(app.ReferenceNo, $"ReferenceNo mismatch for Id={app.Id}"); + rec.RequestedAmount.ShouldBe(app.RequestedAmount, $"RequestedAmount mismatch for Id={app.Id}"); + rec.TotalProjectBudget.ShouldBe(app.TotalProjectBudget, $"TotalProjectBudget mismatch for Id={app.Id}"); + rec.EconomicRegion.ShouldBe(app.EconomicRegion, $"EconomicRegion mismatch for Id={app.Id}"); + rec.City.ShouldBe(app.City, $"City mismatch for Id={app.Id}"); + rec.SubmissionDate.ShouldBe(app.SubmissionDate, $"SubmissionDate mismatch for Id={app.Id}"); + rec.OwnerId.ShouldBe(app.OwnerId, $"OwnerId mismatch for Id={app.Id}"); + rec.ApplicantId.ShouldBe(app.ApplicantId, $"ApplicantId mismatch for Id={app.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_StatusAndCategory_Match_WithFullDetailsAsync() + { + using var uow = _unitOfWorkManager.Begin(); + + var fullDetails = await _applicationRepository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: int.MaxValue); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + foreach (var app in fullDetails) + { + var rec = listRecords.First(r => r.Id == app.Id); + + rec.Status.ShouldBe(app.ApplicationStatus.InternalStatus, + $"Status (InternalStatus) mismatch for Id={app.Id}"); + rec.Category.ShouldBe(app.ApplicationForm.Category ?? string.Empty, + $"Category mismatch for Id={app.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_ApplicantFields_Match_WithFullDetailsAsync() + { + using var uow = _unitOfWorkManager.Begin(); + + var fullDetails = await _applicationRepository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: int.MaxValue); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + foreach (var app in fullDetails) + { + var rec = listRecords.First(r => r.Id == app.Id); + var applicant = app.Applicant; + + rec.ApplicantName.ShouldBe(applicant.ApplicantName, + $"ApplicantName mismatch for Id={app.Id}"); + rec.ApplicantOrgName.ShouldBe(applicant.OrgName, + $"ApplicantOrgName mismatch for Id={app.Id}"); + rec.ApplicantOrgNumber.ShouldBe(applicant.OrgNumber, + $"ApplicantOrgNumber mismatch for Id={app.Id}"); + rec.ApplicantOrgStatus.ShouldBe(applicant.OrgStatus, + $"ApplicantOrgStatus mismatch for Id={app.Id}"); + rec.ApplicantSector.ShouldBe(applicant.Sector, + $"ApplicantSector mismatch for Id={app.Id}"); + rec.ApplicantSubSector.ShouldBe(applicant.SubSector, + $"ApplicantSubSector mismatch for Id={app.Id}"); + rec.ApplicantOrganizationType.ShouldBe(applicant.OrganizationType, + $"ApplicantOrganizationType mismatch for Id={app.Id}"); + rec.ApplicantOrganizationSize.ShouldBe(applicant.OrganizationSize, + $"ApplicantOrganizationSize mismatch for Id={app.Id}"); + rec.ApplicantIndigenousOrgInd.ShouldBe(applicant.IndigenousOrgInd, + $"ApplicantIndigenousOrgInd mismatch for Id={app.Id}"); + rec.ApplicantRedStop.ShouldBe(applicant.RedStop, + $"ApplicantRedStop mismatch for Id={app.Id}"); + rec.ApplicantUnityApplicantId.ShouldBe(applicant.UnityApplicantId, + $"ApplicantUnityApplicantId mismatch for Id={app.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_CollectionCounts_Match_WithFullDetailsAsync() + { + using var uow = _unitOfWorkManager.Begin(); + + var fullDetails = await _applicationRepository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: int.MaxValue); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + foreach (var app in fullDetails) + { + var rec = listRecords.First(r => r.Id == app.Id); + + rec.Tags.Count.ShouldBe( + app.ApplicationTags?.Count ?? 0, + $"Tag count mismatch for Id={app.Id}"); + rec.Assignments.Count.ShouldBe( + app.ApplicationAssignments?.Count ?? 0, + $"Assignment count mismatch for Id={app.Id}"); + rec.Links.Count.ShouldBe( + app.ApplicationLinks?.Count ?? 0, + $"Link count mismatch for Id={app.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_ContactFields_Match_WithFullDetailsAsync() + { + using var uow = _unitOfWorkManager.Begin(); + + var fullDetails = await _applicationRepository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: int.MaxValue); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + foreach (var app in fullDetails) + { + var rec = listRecords.First(r => r.Id == app.Id); + + rec.ContactFullName.ShouldBe(app.ApplicantAgent?.Name, + $"ContactFullName mismatch for Id={app.Id}"); + rec.ContactTitle.ShouldBe(app.ApplicantAgent?.Title, + $"ContactTitle mismatch for Id={app.Id}"); + rec.ContactEmail.ShouldBe(app.ApplicantAgent?.Email, + $"ContactEmail mismatch for Id={app.Id}"); + rec.ContactBusinessPhone.ShouldBe(app.ApplicantAgent?.Phone, + $"ContactBusinessPhone mismatch for Id={app.Id}"); + rec.ContactCellPhone.ShouldBe(app.ApplicantAgent?.Phone2, + $"ContactCellPhone mismatch for Id={app.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_OwnerFields_Match_WithFullDetailsAsync() + { + using var uow = _unitOfWorkManager.Begin(); + + var fullDetails = await _applicationRepository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: int.MaxValue); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + foreach (var app in fullDetails) + { + var rec = listRecords.First(r => r.Id == app.Id); + + if (app.Owner != null) + { + rec.OwnerPersonId.ShouldBe(app.Owner.Id, + $"OwnerPersonId mismatch for Id={app.Id}"); + rec.OwnerFullName.ShouldBe(app.Owner.FullName, + $"OwnerFullName mismatch for Id={app.Id}"); + } + else + { + rec.OwnerPersonId.ShouldBeNull($"Expected null OwnerPersonId for Id={app.Id}"); + rec.OwnerFullName.ShouldBeNull($"Expected null OwnerFullName for Id={app.Id}"); + } + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_KnownApplication1_HasExpectedFieldValues() + { + using var uow = _unitOfWorkManager.Begin(); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + var rec = listRecords.FirstOrDefault(r => r.Id == GrantManagerTestData.Application1_Id); + rec.ShouldNotBeNull("Application1 seed data should be present in list records"); + + rec.ProjectName.ShouldBe("Application For Integration Test Funding"); + rec.ReferenceNo.ShouldBe("TEST12345"); + rec.RequestedAmount.ShouldBe(3456.13m); + rec.ApplicantId.ShouldBe(GrantManagerTestData.Applicant1_Id); + rec.ApplicantName.ShouldBe("Integration Tester 1"); + rec.Status.ShouldBe("Submitted"); + rec.SubmissionDate.ShouldBe(new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_KnownApplication2_HasExpectedFieldValues() + { + using var uow = _unitOfWorkManager.Begin(); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + var rec = listRecords.FirstOrDefault(r => r.Id == GrantManagerTestData.Application2_Id); + rec.ShouldNotBeNull("Application2 seed data should be present in list records"); + + rec.ProjectName.ShouldBe("Application 2 For Integration Test Funding"); + rec.ReferenceNo.ShouldBe("TEST67890"); + rec.RequestedAmount.ShouldBe(5000m); + rec.ApplicantId.ShouldBe(GrantManagerTestData.Applicant1_Id); + rec.ApplicantName.ShouldBe("Integration Tester 1"); + rec.Status.ShouldBe("Submitted"); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithTagsRequestedField_ExcludesContactAndOwnerData() + { + using var uow = _unitOfWorkManager.Begin(); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: new List { "applicationTag" }); + + listRecords.ShouldNotBeEmpty(); + + foreach (var rec in listRecords) + { + rec.ContactFullName.ShouldBeNull( + $"ContactFullName should not be loaded when only 'applicationTag' is requested for Id={rec.Id}"); + rec.OwnerPersonId.ShouldBeNull( + $"OwnerPersonId should not be loaded when only 'applicationTag' is requested for Id={rec.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithAssigneesRequestedField_ExcludesContactAndOwnerData() + { + using var uow = _unitOfWorkManager.Begin(); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: new List { "assignees" }); + + listRecords.ShouldNotBeEmpty(); + + foreach (var rec in listRecords) + { + rec.ContactFullName.ShouldBeNull( + $"ContactFullName should not be loaded when only 'assignees' is requested for Id={rec.Id}"); + rec.OwnerPersonId.ShouldBeNull( + $"OwnerPersonId should not be loaded when only 'assignees' is requested for Id={rec.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithContactFieldRequested_ExcludesTagsAssignmentsLinks() + { + using var uow = _unitOfWorkManager.Begin(); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: new List { "contactEmail" }); + + listRecords.ShouldNotBeEmpty(); + + foreach (var rec in listRecords) + { + rec.Tags.ShouldBeEmpty( + $"Tags should not be loaded when only contact fields are requested for Id={rec.Id}"); + rec.Assignments.ShouldBeEmpty( + $"Assignments should not be loaded when only contact fields are requested for Id={rec.Id}"); + rec.Links.ShouldBeEmpty( + $"Links should not be loaded when only contact fields are requested for Id={rec.Id}"); + rec.OwnerPersonId.ShouldBeNull( + $"OwnerPersonId should not be loaded when only contact fields are requested for Id={rec.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithOwnerRequestedField_ExcludesTagsAssignmentsLinks() + { + using var uow = _unitOfWorkManager.Begin(); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: new List { "Owner" }); + + listRecords.ShouldNotBeEmpty(); + + foreach (var rec in listRecords) + { + rec.Tags.ShouldBeEmpty( + $"Tags should not be loaded when only 'Owner' is requested for Id={rec.Id}"); + rec.Assignments.ShouldBeEmpty( + $"Assignments should not be loaded when only 'Owner' is requested for Id={rec.Id}"); + rec.Links.ShouldBeEmpty( + $"Links should not be loaded when only 'Owner' is requested for Id={rec.Id}"); + rec.ContactFullName.ShouldBeNull( + $"ContactFullName should not be loaded when only 'Owner' is requested for Id={rec.Id}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithEmptyRequestedFields_ExcludesAllOptionalData() + { + using var uow = _unitOfWorkManager.Begin(); + + var listRecords = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: new List()); + + listRecords.ShouldNotBeEmpty(); + + foreach (var rec in listRecords) + { + rec.Tags.ShouldBeEmpty( + $"Tags should not be loaded when requestedFields is empty for Id={rec.Id}"); + rec.Assignments.ShouldBeEmpty( + $"Assignments should not be loaded when requestedFields is empty for Id={rec.Id}"); + rec.Links.ShouldBeEmpty( + $"Links should not be loaded when requestedFields is empty for Id={rec.Id}"); + rec.ContactFullName.ShouldBeNull( + $"ContactFullName should not be loaded when requestedFields is empty for Id={rec.Id}"); + rec.OwnerPersonId.ShouldBeNull( + $"OwnerPersonId should not be loaded when requestedFields is empty for Id={rec.Id}"); + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationListTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationListTests.cs new file mode 100644 index 000000000..730926281 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationListTests.cs @@ -0,0 +1,296 @@ +using Shouldly; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Unity.GrantManager.Applications; + +using Volo.Abp.Uow; + +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.GrantApplications; + +public class GrantApplicationListTests : GrantManagerApplicationTestBase +{ + private readonly IGrantApplicationAppService _appService; + private readonly IApplicationRepository _applicationRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public GrantApplicationListTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + _appService = GetRequiredService(); + _applicationRepository = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + + // GetApplicationListRecordsAsync -- requestedFields flag logic + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_NullRequestedFields_ReturnsSeededApplicationsWithBaseFields() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: null); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + result.ShouldAllBe(r => r.Status != null); + result.ShouldAllBe(r => r.ApplicantName != null); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithNonContactNonOwnerFields_OmitsAgentAndOwnerData() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: ["projectName", "referenceNo"]); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + + // Agent fields should be null -- not requested + result.ShouldAllBe(r => r.ContactFullName == null); + result.ShouldAllBe(r => r.ContactEmail == null); + result.ShouldAllBe(r => r.ContactTitle == null); + + // Owner fields should be null -- not requested + result.ShouldAllBe(r => r.OwnerFullName == null); + + // Collections should be empty -- not requested + result.ShouldAllBe(r => r.Tags.Count == 0); + result.ShouldAllBe(r => r.Assignments.Count == 0); + result.ShouldAllBe(r => r.Links.Count == 0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithContactFields_RunsAgentPath_AssignmentsAndLinksOmitted() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: ["contactFullName", "contactEmail"]); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + result.ShouldAllBe(r => r.ApplicantName != null); + result.ShouldAllBe(r => r.Tags.Count == 0); + result.ShouldAllBe(r => r.Assignments.Count == 0); + result.ShouldAllBe(r => r.Links.Count == 0); + result.ShouldAllBe(r => r.OwnerFullName == null); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithTagField_RunsTagsPath_AgentAndOwnerOmitted() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: ["applicationTag"]); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + result.ShouldAllBe(r => r.Tags != null); + result.ShouldAllBe(r => r.ContactFullName == null); + result.ShouldAllBe(r => r.OwnerFullName == null); + result.ShouldAllBe(r => r.Assignments.Count == 0); + result.ShouldAllBe(r => r.Links.Count == 0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithOwnerField_RunsOwnerPath_AgentAndTagsOmitted() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: ["Owner"]); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + result.ShouldAllBe(r => r.ContactFullName == null); + result.ShouldAllBe(r => r.Tags.Count == 0); + result.ShouldAllBe(r => r.Assignments.Count == 0); + result.ShouldAllBe(r => r.Links.Count == 0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithAssigneesField_RunsAssignmentsPath_TagsOmitted() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: ["assignees"]); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + result.ShouldAllBe(r => r.Assignments != null); + result.ShouldAllBe(r => r.Tags.Count == 0); + result.ShouldAllBe(r => r.Links.Count == 0); + result.ShouldAllBe(r => r.ContactFullName == null); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithApplicationLinksField_RunsLinksPath_TagsOmitted() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + requestedFields: ["applicationLinks"]); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + result.ShouldAllBe(r => r.Links != null); + result.ShouldAllBe(r => r.Tags.Count == 0); + result.ShouldAllBe(r => r.Assignments.Count == 0); + result.ShouldAllBe(r => r.ContactFullName == null); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithFutureDateFilter_ReturnsEmpty() + { + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + submittedFromDate: DateTime.UtcNow.AddYears(10)); + + result.ShouldNotBeNull(); + result.Count.ShouldBe(0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetApplicationListRecordsAsync_WithDateRangeMatchingSeededData_ReturnsApplications() + { + // Seeded applications have SubmissionDate = 2023-01-01 + using var uow = _unitOfWorkManager.Begin(); + + var result = await _applicationRepository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: int.MaxValue, + submittedFromDate: new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), + submittedToDate: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + result.ShouldNotBeNull(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + } + + + // GetListAsync -- totalCount == items.Count and requestedFields pass-through + [Fact] + [Trait("Category", "Integration")] + public async Task GetListAsync_TotalCount_EqualsItemsCount() + { + var result = await _appService.GetListAsync(new GrantApplicationListInputDto()); + + result.ShouldNotBeNull(); + result.TotalCount.ShouldBe(result.Items.Count); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetListAsync_ReturnsAtLeastOneItem() + { + var result = await _appService.GetListAsync(new GrantApplicationListInputDto()); + + result.Items.Count.ShouldBeGreaterThanOrEqualTo(1); + result.TotalCount.ShouldBeGreaterThanOrEqualTo(1); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetListAsync_WithFutureDateFilter_ReturnsEmptyPagedResult() + { + var result = await _appService.GetListAsync(new GrantApplicationListInputDto + { + SubmittedFromDate = DateTime.UtcNow.AddYears(10) + }); + + result.ShouldNotBeNull(); + result.Items.Count.ShouldBe(0); + result.TotalCount.ShouldBe(0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetListAsync_WithNullRequestedFields_AllItemsHaveBaseFields() + { + var result = await _appService.GetListAsync(new GrantApplicationListInputDto + { + RequestedFields = null + }); + + result.ShouldNotBeNull(); + result.Items.Count.ShouldBeGreaterThanOrEqualTo(1); + result.TotalCount.ShouldBe(result.Items.Count); + result.Items.ShouldAllBe(dto => !string.IsNullOrEmpty(dto.Status)); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetListAsync_WithContactRequestedFields_TotalCountMatchesItems() + { + var result = await _appService.GetListAsync(new GrantApplicationListInputDto + { + RequestedFields = new List { "contactFullName", "contactEmail" } + }); + + result.ShouldNotBeNull(); + result.TotalCount.ShouldBe(result.Items.Count); + result.Items.ShouldAllBe(dto => !string.IsNullOrEmpty(dto.Status)); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetListAsync_WithoutContactRequestedFields_ContactFieldsAreNull() + { + var result = await _appService.GetListAsync(new GrantApplicationListInputDto + { + RequestedFields = new List { "projectName", "referenceNo" } + }); + + result.ShouldNotBeNull(); + result.Items.ShouldAllBe(dto => dto.ContactFullName == null); + result.Items.ShouldAllBe(dto => dto.ContactEmail == null); + result.Items.ShouldAllBe(dto => dto.ContactTitle == null); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetListAsync_TotalCountEqualsItemsCount_IsConsistentAcrossMultipleCalls() + { + var result1 = await _appService.GetListAsync(new GrantApplicationListInputDto()); + var result2 = await _appService.GetListAsync(new GrantApplicationListInputDto()); + + result1.TotalCount.ShouldBe(result1.Items.Count); + result2.TotalCount.ShouldBe(result2.Items.Count); + result1.TotalCount.ShouldBe(result2.TotalCount); + } +} \ No newline at end of file From 330f19c6993d932e6366c79ebee1255181aaddbc Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Thu, 7 May 2026 15:41:59 -0700 Subject: [PATCH 11/21] AB#32545: Update Assessment Buttons Styling, Order, and Text --- .../Components/ReviewList/ReviewList.css | 6 + .../Components/ReviewList/ReviewList.js | 198 +++++++++--------- 2 files changed, 105 insertions(+), 99 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css index f259658c3..6d767358f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css @@ -14,3 +14,9 @@ table.dataTable > tbody > tr.even.selected > * { display: inline-flex; align-items: center; } + +#AdjudicationTeamLeadActionBar .btn, +#ReviewListTable_wrapper .dt-buttons .btn { + text-transform: none; + background-color: var(--bs-btn-bg) !important; +} 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 0da7f980c..2174ef88b 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 @@ -4,20 +4,20 @@ const isAiScoringEnabled = document.querySelector("#ReviewListAIScoringEnabled") const canUseAiScoring = isAiScoringEnabled; const actionButtonConfigMap = { - Generate: { buttonType: 'generateAiButton', order: 1 }, - Clone: { buttonType: 'cloneButton', order: 2 }, - Create: { buttonType: 'createButton', order: 3 }, - SendBack: { buttonType: 'unityWorkflow', order: 4 }, - Complete: { buttonType: 'unityWorkflow', order: 5 }, + Generate: { buttonType: 'generateAiButton', order: 4 }, + Clone: { buttonType: 'cloneButton', order: 5 }, + Create: { buttonType: 'createButton', order: 1 }, + SendBack: { buttonType: 'unityWorkflow', order: 3 }, + Complete: { buttonType: 'unityWorkflow', order: 2 }, _Fallback: { buttonType: 'unityWorkflow', order: 100 } } const actionButtonLabelMap = { Generate: 'Generate', Clone: 'Clone', - Create: 'Create', + Create: 'Create Assessment', SendBack: 'Send Back', - Complete: 'Complete' + Complete: 'Complete Assessment' }; const finalApplicationStates = [ @@ -54,7 +54,7 @@ $(function () { $.extend(DataTable.ext.buttons, { unityWorkflow: { - className: 'btn unt-btn-outline-primary btn-outline-primary', + className: 'btn btn-light rounded-1', enabled: false, text: unityWorkflowButtonText, action: unityWorkflowButtonAction @@ -455,97 +455,97 @@ function unityWorkflowButtonAction(e, dt, button, config) { } } -function generateAiButtonAction(e, dt, button, config) { - const $button = button?.node ? $(button.node) : 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') - .done(function (request) { - const status = globalThis.AIGenerationButtonState?.resolveStatus(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) - .done(function (request) { - const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; - - if (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'); +function generateAiButtonAction(e, dt, button, config) { + const $button = button?.node ? $(button.node) : 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') + .done(function (request) { + const status = globalThis.AIGenerationButtonState?.resolveStatus(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) + .done(function (request) { + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + + if (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( "Completed Successfully", From d372c912e7c7feb874383fe587c1743ef4161c49 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 7 May 2026 16:36:27 -0700 Subject: [PATCH 12/21] AB#32683 report history mapping --- .../GrantManagerApplicationMapperlyProfile.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs index 3539730fa..c2eca7860 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs @@ -734,6 +734,41 @@ public partial class AuditHistoryDtoToEntityMapper : MapperBase { public override partial ReportsHistoryDto Map(ReportsHistory source); public override partial void Map(ReportsHistory source, ReportsHistoryDto destination); } +[Mapper] +public partial class CreateUpdateReportsHistoryDtoToEntityMapper : MapperBase +{ + [MapperIgnoreTarget(nameof(ReportsHistory.Id))] + [MapperIgnoreTarget(nameof(ReportsHistory.TenantId))] + [MapperIgnoreTarget(nameof(ReportsHistory.ConcurrencyStamp))] + [MapperIgnoreTarget(nameof(ReportsHistory.CreationTime))] + [MapperIgnoreTarget(nameof(ReportsHistory.CreatorId))] + [MapperIgnoreTarget(nameof(ReportsHistory.LastModificationTime))] + [MapperIgnoreTarget(nameof(ReportsHistory.LastModifierId))] + public override partial ReportsHistory Map(CreateUpdateReportsHistoryDto source); + + [MapperIgnoreTarget(nameof(ReportsHistory.Id))] + [MapperIgnoreTarget(nameof(ReportsHistory.TenantId))] + [MapperIgnoreTarget(nameof(ReportsHistory.ConcurrencyStamp))] + [MapperIgnoreTarget(nameof(ReportsHistory.CreationTime))] + [MapperIgnoreTarget(nameof(ReportsHistory.CreatorId))] + [MapperIgnoreTarget(nameof(ReportsHistory.LastModificationTime))] + [MapperIgnoreTarget(nameof(ReportsHistory.LastModifierId))] + public override partial void Map(CreateUpdateReportsHistoryDto source, ReportsHistory destination); +} + +[Mapper] +public partial class ReportsHistoryDtoToEntityMapper : MapperBase +{ + [MapperIgnoreTarget(nameof(ReportsHistory.TenantId))] + [MapperIgnoreTarget(nameof(ReportsHistory.ConcurrencyStamp))] + public override partial ReportsHistory Map(ReportsHistoryDto source); + + [MapperIgnoreTarget(nameof(ReportsHistory.TenantId))] + [MapperIgnoreTarget(nameof(ReportsHistory.ConcurrencyStamp))] + public override partial void Map(ReportsHistoryDto source, ReportsHistory destination); +} + [Mapper] public partial class ApplicationToApplicantInfoDtoMapper : MapperBase { From 7406cfe2055a3727e8c7ca64b14c5ab1073e301e Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 11:39:32 -0700 Subject: [PATCH 13/21] AB#32903 simplify AI provider payload validator helpers --- .../AI/Runtime/AIProviderPayloadValidator.cs | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index f34cb9509..06016a970 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -19,16 +19,11 @@ public static bool IsValidApplicationAnalysisJson(string response) return false; } - return root.TryGetProperty(AIJsonKeys.Decision, out var decision) - && decision.ValueKind == JsonValueKind.String - && root.TryGetProperty(AIJsonKeys.Errors, out var errors) - && errors.ValueKind == JsonValueKind.Array - && root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) - && warnings.ValueKind == JsonValueKind.Array - && root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) - && summaries.ValueKind == JsonValueKind.Array - && root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) - && recommendations.ValueKind == JsonValueKind.Array; + return HasStringProperty(root, AIJsonKeys.Decision) && + HasArrayProperty(root, AIJsonKeys.Errors) && + HasArrayProperty(root, AIJsonKeys.Warnings) && + HasArrayProperty(root, AIJsonKeys.Summaries) && + HasArrayProperty(root, AIJsonKeys.Recommendations); } public static bool IsValidApplicationScoringJson(string response, string sectionJson) @@ -46,24 +41,17 @@ public static bool IsValidApplicationScoringJson(string response, string section foreach (var questionId in expectedQuestionIds) { - if (!root.TryGetProperty(questionId, out var answerObject) || answerObject.ValueKind != JsonValueKind.Object) + if (!TryGetRequiredObject(root, questionId, out var answerObject)) { return false; } - if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerValue) - || answerValue.ValueKind == JsonValueKind.Null - || answerValue.ValueKind == JsonValueKind.Object - || answerValue.ValueKind == JsonValueKind.Array) + if (!HasPrimitiveProperty(answerObject, AIJsonKeys.Answer)) { return false; } - if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) - || confidenceValue.ValueKind != JsonValueKind.Number - || !confidenceValue.TryGetInt32(out var confidence) - || confidence < 0 - || confidence > 100) + if (!IsValidConfidenceProperty(answerObject, AIJsonKeys.Confidence)) { return false; } @@ -72,6 +60,41 @@ public static bool IsValidApplicationScoringJson(string response, string section return true; } + private static bool HasStringProperty(JsonElement element, string name) + { + return element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String; + } + + private static bool HasArrayProperty(JsonElement element, string name) + { + return element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.Array; + } + + private static bool TryGetRequiredObject(JsonElement element, string name, out JsonElement value) + { + return element.TryGetProperty(name, out value) && + value.ValueKind == JsonValueKind.Object; + } + + private static bool HasPrimitiveProperty(JsonElement element, string name) + { + return element.TryGetProperty(name, out var property) && + property.ValueKind != JsonValueKind.Null && + property.ValueKind != JsonValueKind.Object && + property.ValueKind != JsonValueKind.Array; + } + + private static bool IsValidConfidenceProperty(JsonElement element, string name) + { + return element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.Number && + property.TryGetInt32(out var confidence) && + confidence >= 0 && + confidence <= 100; + } + private static HashSet ExtractQuestionIds(string sectionJson) { var ids = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -146,6 +169,5 @@ private static bool TryParseRootObject(string response, out JsonElement root) return false; } } - } } From fb2fcdc7022913bc75db8013095aa5eb9fd22ee0 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 11:14:51 -0700 Subject: [PATCH 14/21] AB#32898 consolidate AI JSON serializer defaults --- .../Operations/ApplicationAnalysisService.cs | 8 ++------ .../Operations/ApplicationScoringService.cs | 20 +++++-------------- .../AI/Runtime/AIJsonDefaults.cs | 20 +++++++++++++++++++ .../AI/Runtime/OpenAIPromptRenderer.cs | 12 +++++------ .../AI/Runtime/OpenAIRuntimeService.cs | 18 ++++++++--------- 5 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIJsonDefaults.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs index 473a50d5b..1e216650f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs @@ -8,6 +8,7 @@ using Unity.AI.Models; using Unity.AI.Prompts; using Unity.AI.Requests; +using Unity.AI.Runtime; using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; @@ -21,11 +22,6 @@ public class ApplicationAnalysisService( IAIService aiService, ILogger logger) : IApplicationAnalysisService, ITransientDependency { - private readonly JsonSerializerOptions _jsonOptionsIndented = new() - { - WriteIndented = true - }; - private const string ComponentsKey = "components"; private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) { @@ -62,7 +58,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro PromptVersion = promptVersion, }); - var analysisJson = JsonSerializer.Serialize(analysis, _jsonOptionsIndented); + var analysisJson = JsonSerializer.Serialize(analysis, AIJsonDefaults.Indented); application.AIAnalysis = analysisJson; await applicationRepository.UpdateAsync(application); return analysisJson; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index b030aad9c..64fee716a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -8,6 +8,7 @@ using Unity.AI.Models; using Unity.AI.Prompts; using Unity.AI.Requests; +using Unity.AI.Runtime; using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; @@ -24,17 +25,6 @@ public class ApplicationScoringService( AIExecutionModeResolver executionModeResolver, ILogger logger) : IApplicationScoringService, ITransientDependency { - private readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - private readonly JsonSerializerOptions _jsonOptionsIndented = new() - { - WriteIndented = true - }; - public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null) { var application = await applicationRepository.GetAsync(applicationId); @@ -82,7 +72,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro } } - var combinedResults = JsonSerializer.Serialize(allSectionResults, _jsonOptionsIndented); + var combinedResults = JsonSerializer.Serialize(allSectionResults, AIJsonDefaults.Indented); var validatedJson = ValidateApplicationScoringJson(combinedResults); application.AIScoresheetAnswers = validatedJson; await applicationRepository.UpdateAsync(application); @@ -104,7 +94,7 @@ private async Task> ProcessSectionAsync( Data = promptData, Attachments = attachmentSummaries, SectionName = section.Name, - SectionSchema = JsonSerializer.SerializeToElement(BuildSectionQuestionsData(section), _jsonOptions), + SectionSchema = JsonSerializer.SerializeToElement(BuildSectionQuestionsData(section), AIJsonDefaults.IndentedCamelCase), PromptVersion = promptVersion, }; var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest); @@ -141,7 +131,7 @@ private async Task>> ProcessSectionsAsync( Data = promptData, Attachments = attachmentSummaries, SectionName = "All Sections", - SectionSchema = JsonSerializer.SerializeToElement(questions, _jsonOptions), + SectionSchema = JsonSerializer.SerializeToElement(questions, AIJsonDefaults.IndentedCamelCase), PromptVersion = promptVersion, }; var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest); @@ -182,7 +172,7 @@ private static List BuildSectionQuestionsData(ScoresheetSection section) private void CopyAnswers(Dictionary answers, Dictionary results) { - var answersJson = JsonSerializer.Serialize(answers, _jsonOptions); + var answersJson = JsonSerializer.Serialize(answers, AIJsonDefaults.IndentedCamelCase); using var answersDoc = JsonDocument.Parse(answersJson); foreach (var property in answersDoc.RootElement.EnumerateObject()) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIJsonDefaults.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIJsonDefaults.cs new file mode 100644 index 000000000..29e2e0ee3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIJsonDefaults.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace Unity.AI.Runtime; + +internal static class AIJsonDefaults +{ + internal static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + + internal static readonly JsonSerializerOptions IndentedCamelCase = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + static AIJsonDefaults() + { + Indented.MakeReadOnly(); + IndentedCamelCase.MakeReadOnly(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs index a56d56f6a..e011df881 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs @@ -26,7 +26,7 @@ public class OpenAIPromptRenderer : ITransientDependency [PromptVersionV1] = PromptVersionV1 }; private static readonly ConcurrentDictionary PromptTemplateCache = new(StringComparer.OrdinalIgnoreCase); - private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + public static string BuildApplicationAnalysisSystemPrompt(string version) { @@ -111,7 +111,7 @@ public static string BuildApplicationScoringResponseTemplate(string sectionPaylo return "{}"; } - return JsonSerializer.Serialize(template, JsonLogOptions); + return JsonSerializer.Serialize(template, AIJsonDefaults.Indented); } catch (JsonException) { @@ -125,7 +125,7 @@ public static string BuildAliasedApplicationScoringSection(string? sectionName, if (string.IsNullOrWhiteSpace(sectionJson)) { - return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, JsonLogOptions); + return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, AIJsonDefaults.Indented); } try @@ -133,7 +133,7 @@ public static string BuildAliasedApplicationScoringSection(string? sectionName, using var sectionDoc = JsonDocument.Parse(sectionJson); if (sectionDoc.RootElement.ValueKind != JsonValueKind.Array) { - return JsonSerializer.Serialize(new { name = sectionName, questions = sectionDoc.RootElement.Clone() }, JsonLogOptions); + return JsonSerializer.Serialize(new { name = sectionName, questions = sectionDoc.RootElement.Clone() }, AIJsonDefaults.Indented); } var aliasedQuestions = new List>(); @@ -174,11 +174,11 @@ public static string BuildAliasedApplicationScoringSection(string? sectionName, } questionIdAliasMap = aliasMap; - return JsonSerializer.Serialize(new { name = sectionName, questions = aliasedQuestions }, JsonLogOptions); + return JsonSerializer.Serialize(new { name = sectionName, questions = aliasedQuestions }, AIJsonDefaults.Indented); } catch (JsonException) { - return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, JsonLogOptions); + return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, AIJsonDefaults.Indented); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 7d7ad49e3..b83ae88db 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -44,7 +44,7 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string PromptLogDirectoryName = "logs"; private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log"; - private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + public OpenAIRuntimeService( IConfiguration configuration, @@ -86,8 +86,8 @@ public async Task GenerateApplicationAnalysisAsync( { ArgumentNullException.ThrowIfNull(request); var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType)); - var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); - var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); + var data = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented); + var schema = JsonSerializer.Serialize(request.Schema, AIJsonDefaults.Indented); var attachmentsPayload = request.Attachments .Select(a => new @@ -97,7 +97,7 @@ public async Task GenerateApplicationAnalysisAsync( }) .Cast(); - var attachments = JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions); + var attachments = JsonSerializer.Serialize(attachmentsPayload, AIJsonDefaults.Indented); var systemPrompt = OpenAIPromptRenderer.BuildApplicationAnalysisSystemPrompt(promptVersion); var applicationAnalysisContent = OpenAIPromptRenderer.BuildApplicationAnalysisUserPrompt( promptVersion, @@ -152,7 +152,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta contentType, text = attachmentText }; - var attachment = JsonSerializer.Serialize(attachmentPayload, JsonLogOptions); + var attachment = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); var contentToAnalyze = OpenAIPromptRenderer.BuildAttachmentSummaryUserPrompt(promptVersion, attachment); await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze); @@ -195,8 +195,8 @@ public async Task GenerateApplicationScoringAsync(Ap { ArgumentNullException.ThrowIfNull(request); var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationScoringPromptType)); - var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); - var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); + var dataJson = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented); + var sectionJson = JsonSerializer.Serialize(request.SectionSchema, AIJsonDefaults.Indented); var attachmentSummaries = request.Attachments .Select(a => $"{a.Name}: {a.Summary}") @@ -410,7 +410,7 @@ private static string FormatPromptOutputForLog(string output) if (TryParseJsonObjectFromResponse(output, out var jsonObject)) { - return JsonSerializer.Serialize(jsonObject, JsonLogOptions); + return JsonSerializer.Serialize(jsonObject, AIJsonDefaults.Indented); } return output.Trim(); @@ -500,7 +500,7 @@ private static string FormatPromptOutputContent(string content) { if (TryParseJsonObjectFromResponse(content, out var contentObject)) { - return JsonSerializer.Serialize(contentObject, JsonLogOptions); + return JsonSerializer.Serialize(contentObject, AIJsonDefaults.Indented); } return content.Trim(); From 28021f4f8138a3daa32e2e02b3fc17676d4a3da7 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 11:47:34 -0700 Subject: [PATCH 15/21] AB#32902 add AI runtime validation tests --- .../Properties/AssemblyInfo.cs | 3 + .../AIProviderPayloadValidatorTests.cs | 123 ++++++++++++++++++ .../AI/Runtime/AIResponseJsonTests.cs | 53 ++++++++ 3 files changed, 179 insertions(+) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Properties/AssemblyInfo.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIResponseJsonTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Properties/AssemblyInfo.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..46526950c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Unity.GrantManager.Application.Tests")] diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs new file mode 100644 index 000000000..d84d2e55a --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs @@ -0,0 +1,123 @@ +using Shouldly; +using Unity.AI.Runtime; +using Xunit; + +namespace Unity.GrantManager.AI.Runtime; + +public class AIProviderPayloadValidatorTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("Some summary text", true)] + public void IsValidAttachmentSummaryText_Should_RejectBlankAndAcceptContent(string? input, bool expected) + { + AIProviderPayloadValidator.IsValidAttachmentSummaryText(input!).ShouldBe(expected); + } + + [Fact] + public void IsValidApplicationAnalysisJson_Should_ReturnTrue_ForWellFormedPayload() + { + var json = """ + { + "decision": "Approved", + "errors": [], + "warnings": [], + "summaries": [], + "recommendations": [] + } + """; + AIProviderPayloadValidator.IsValidApplicationAnalysisJson(json).ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not json")] + [InlineData("[]")] + public void IsValidApplicationAnalysisJson_Should_ReturnFalse_ForInvalidInput(string? input) + { + AIProviderPayloadValidator.IsValidApplicationAnalysisJson(input!).ShouldBeFalse(); + } + + [Fact] + public void IsValidApplicationAnalysisJson_Should_ReturnFalse_WhenDecisionMissing() + { + var json = """{"errors":[],"warnings":[],"summaries":[],"recommendations":[]}"""; + AIProviderPayloadValidator.IsValidApplicationAnalysisJson(json).ShouldBeFalse(); + } + + [Fact] + public void IsValidApplicationAnalysisJson_Should_ReturnFalse_WhenErrorsIsNotArray() + { + var json = """{"decision":"ok","errors":"bad","warnings":[],"summaries":[],"recommendations":[]}"""; + AIProviderPayloadValidator.IsValidApplicationAnalysisJson(json).ShouldBeFalse(); + } + + [Fact] + public void IsValidApplicationAnalysisJson_Should_AcceptMarkdownWrappedJson() + { + var json = "```json\n{\"decision\":\"ok\",\"errors\":[],\"warnings\":[],\"summaries\":[],\"recommendations\":[]}\n```"; + AIProviderPayloadValidator.IsValidApplicationAnalysisJson(json).ShouldBeTrue(); + } + + [Fact] + public void IsValidApplicationScoringJson_Should_ReturnTrue_ForWellFormedPayload() + { + var sectionJson = """[{"id":"q1"},{"id":"q2"}]"""; + var response = """ + { + "q1": {"answer": "Yes", "confidence": 85}, + "q2": {"answer": "No", "confidence": 42} + } + """; + AIProviderPayloadValidator.IsValidApplicationScoringJson(response, sectionJson).ShouldBeTrue(); + } + + [Fact] + public void IsValidApplicationScoringJson_Should_ReturnFalse_WhenSectionJsonIsEmpty() + { + AIProviderPayloadValidator.IsValidApplicationScoringJson("{}", "[]").ShouldBeFalse(); + } + + [Fact] + public void IsValidApplicationScoringJson_Should_ReturnFalse_WhenAnswerMissing() + { + var sectionJson = """[{"id":"q1"}]"""; + var response = """{"q1": {"confidence": 50}}"""; + AIProviderPayloadValidator.IsValidApplicationScoringJson(response, sectionJson).ShouldBeFalse(); + } + + [Fact] + public void IsValidApplicationScoringJson_Should_ReturnFalse_WhenConfidenceOutOfRange() + { + var sectionJson = """[{"id":"q1"}]"""; + var response = """{"q1": {"answer": "Yes", "confidence": 150}}"""; + AIProviderPayloadValidator.IsValidApplicationScoringJson(response, sectionJson).ShouldBeFalse(); + } + + [Fact] + public void IsValidApplicationScoringJson_Should_ReturnFalse_WhenQuestionMissingFromResponse() + { + var sectionJson = """[{"id":"q1"},{"id":"q2"}]"""; + var response = """{"q1": {"answer": "Yes", "confidence": 80}}"""; + AIProviderPayloadValidator.IsValidApplicationScoringJson(response, sectionJson).ShouldBeFalse(); + } + + [Fact] + public void IsValidApplicationScoringJson_Should_AcceptQuestionsWrappedInObject() + { + var sectionJson = """{"questions":[{"id":"q1"}]}"""; + var response = """{"q1": {"answer": "Yes", "confidence": 75}}"""; + AIProviderPayloadValidator.IsValidApplicationScoringJson(response, sectionJson).ShouldBeTrue(); + } + + [Fact] + public void IsValidApplicationScoringJson_Should_ReturnFalse_WhenConfidenceIsNegative() + { + var sectionJson = """[{"id":"q1"}]"""; + var response = """{"q1": {"answer": "Yes", "confidence": -1}}"""; + AIProviderPayloadValidator.IsValidApplicationScoringJson(response, sectionJson).ShouldBeFalse(); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIResponseJsonTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIResponseJsonTests.cs new file mode 100644 index 000000000..24daba630 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIResponseJsonTests.cs @@ -0,0 +1,53 @@ +using Shouldly; +using Unity.AI.Runtime; +using Xunit; + +namespace Unity.GrantManager.AI.Runtime; + +public class AIResponseJsonTests +{ + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", "")] + public void CleanJsonResponse_Should_ReturnEmpty_ForNullOrWhitespace(string? input, string expected) + { + AIResponseJson.CleanJsonResponse(input!).ShouldBe(expected); + } + + [Fact] + public void CleanJsonResponse_Should_StripMarkdownJsonFence_WithNewline() + { + var input = "```json\n{\"key\":\"value\"}\n```"; + AIResponseJson.CleanJsonResponse(input).ShouldBe("{\"key\":\"value\"}"); + } + + [Fact] + public void CleanJsonResponse_Should_StripPlainFence_WithNewline() + { + var input = "```\n{\"key\":\"value\"}\n```"; + AIResponseJson.CleanJsonResponse(input).ShouldBe("{\"key\":\"value\"}"); + } + + [Fact] + public void CleanJsonResponse_Should_StripTrailingFence_OnlyAfterContent() + { + var input = "{\"key\":\"value\"}```"; + AIResponseJson.CleanJsonResponse(input).ShouldBe("{\"key\":\"value\"}"); + } + + [Fact] + public void CleanJsonResponse_Should_ReturnTrimmedJson_WithNoFences() + { + var input = " {\"key\":\"value\"} "; + AIResponseJson.CleanJsonResponse(input).ShouldBe("{\"key\":\"value\"}"); + } + + [Fact] + public void CleanJsonResponse_Should_HandleFenceWithoutNewline_UsingFirstJsonToken() + { + var input = "```json{\"key\":\"value\"}```"; + var result = AIResponseJson.CleanJsonResponse(input); + result.ShouldBe("{\"key\":\"value\"}"); + } +} From 5d7c21f5d756b15a32b2e3a91477c03a8d2ed2dd Mon Sep 17 00:00:00 2001 From: David Bright Date: Fri, 8 May 2026 09:51:52 -0700 Subject: [PATCH 16/21] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../GrantApplications/GrantApplicationAppService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 c7d037855..462bb0d17 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -117,7 +117,7 @@ public async Task> GetListAsync(GrantApplica TotalProjectBudget = rec.TotalProjectBudget, EconomicRegion = rec.EconomicRegion ?? string.Empty, City = rec.City ?? string.Empty, - ProposalDate = rec.ProposalDate ?? default, + ProposalDate = rec.ProposalDate, SubmissionDate = rec.SubmissionDate, FinalDecisionDate = rec.FinalDecisionDate, DueDate = rec.DueDate, @@ -133,7 +133,7 @@ public async Task> GetListAsync(GrantApplica DeclineRational = MapDeclineRationalDisplayValue(rec.DeclineRational ?? string.Empty), Notes = rec.Notes ?? string.Empty, AssessmentResultStatus = rec.AssessmentResultStatus ?? string.Empty, - AssessmentResultDate = rec.AssessmentResultDate ?? default, + AssessmentResultDate = rec.AssessmentResultDate, ProjectStartDate = rec.ProjectStartDate, ProjectEndDate = rec.ProjectEndDate, PercentageTotalProjectBudget = rec.PercentageTotalProjectBudget, @@ -165,7 +165,7 @@ public async Task> GetListAsync(GrantApplica // From ApplicationForm Category = rec.Category, - // From Applicant — both the nested DTO and the flattened top-level properties + // From Applicant - both the nested DTO and the flattened top-level properties Applicant = new GrantApplicationApplicantDto { Id = rec.ApplicantId, @@ -255,7 +255,7 @@ public async Task> GetListAsync(GrantApplica //Code is temporarily commented out as this will be the way to get the accurate count //once the core GrantApplications data table is moved server side from client side. //Until then, since it is client side and always requests all records at once to be - //loaded, an extra round-trip to the database for a query is uncessary. + //loaded, an extra round-trip to the database for a query is unnecessary. //var totalCount = await applicationRepository.GetCountAsync(input.SubmittedFromDate,input.SubmittedToDate); #pragma warning restore S125 From 73ad3f8726f535e1b5b302effad82d16f7a7aa40 Mon Sep 17 00:00:00 2001 From: David Bright Date: Fri, 8 May 2026 11:42:03 -0700 Subject: [PATCH 17/21] Pre-warm EF Core, DataTable perf, config & secrets update Added GrantManagerDbWarmupService for cold starts, registered the web module so that its only affects the Web project, and not others like DbMigrator. A lot of minor tweaks to parts of the GrantApplications DataTable initialization and rendering. --Defer render is now on. --Standardized all date calls from luxon.DateTime.fromISO(...).toUTC().toLocaleString() to DateUtils.formatUtcDateToLocal(data, type); --Moved expensive object creations outside of render functions so that they're not re-run on every row render --Added type guards to more expensive render column functions that include HTML, so that when not displayed the function is not run but the internal value is still filterable. --- .../wwwroot/themes/ux2/table-utils.js | 5 +- .../GrantManagerDbWarmupService.cs | 163 +++++++++++++++ .../GrantManagerWebModule.cs | 3 + .../Pages/GrantApplications/Index.js | 189 +++++++++--------- 4 files changed, 258 insertions(+), 102 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js index 8bc5385e7..0b00bdc5e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js @@ -229,7 +229,8 @@ function initializeDataTable(options) { onStateLoadParams, onStateLoaded, fixedHeaders = false, - lengthMenu = [25, 50, 75, 100, -1] + lengthMenu = [25, 50, 75, 100, -1], + deferRender = false } = options; // Process columns and visibility @@ -255,7 +256,7 @@ function initializeDataTable(options) { scrollX: true, scrollCollapse: true, autoWidth: true, - deferRender: false, + deferRender: deferRender, deferLoading: serverSideEnabled ? 0 : null, ajax: abp.libs.datatables.createAjax( dataEndpoint, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs new file mode 100644 index 000000000..9823d4bca --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Unity.GrantManager.Applications; + +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.EntityFrameworkCore; + +/// +/// Background service that pre-warms the EF Core query pipeline after application startup. +/// +/// On first use, EF Core performs three expensive one-time operations: +/// 1. Model snapshot compilation — GrantTenantDbContext.OnModelCreating (30+ entity types) +/// 2. LINQ→SQL expression tree translation — especially costly for multi-JOIN includes +/// 3. Npgsql connection pool establishment + PostgreSQL query plan caching +/// +/// These costs are normally deferred to the first HTTP request, causing 6-8 second cold-start +/// latency for the GrantApplications DataTable. This service fires the most expensive query +/// shape (WithFullDetailsAsync with typical date filters) shortly after startup so the cache +/// is warm before any user makes a request. +/// +/// Warmup is split into two independent phases: +/// Phase 1 (model compilation) — always succeeds; no DB connection required. +/// Phase 2 (per-tenant DB round-trip) — iterates every tenant from the host database and +/// warms Npgsql's connection pool and PostgreSQL's query plan cache for each, ensuring no +/// tenant's first user pays the connection-establishment cost. +/// +/// +public class GrantManagerDbWarmupService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public GrantManagerDbWarmupService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Allow ABP's OnApplicationInitialization and any module bootstrapping to fully complete before issuing queries. + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + + if (stoppingToken.IsCancellationRequested) return; + + _logger.LogInformation("[DbWarmup] Starting EF Core query pipeline warmup."); + + // Step 1: Model + // Accessing dbContext.Model forces EF Core to run OnModelCreating synchronously. + // This is a pure in-process operation; no DB connection is opened. + using (var phase1Scope = _scopeFactory.CreateScope()) + { + var unitOfWorkManager = phase1Scope.ServiceProvider.GetRequiredService(); + try + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var dbContextProvider = phase1Scope.ServiceProvider + .GetRequiredService>(); + var dbContext = await dbContextProvider.GetDbContextAsync(); + + // Accessing Model triggers OnModelCreating if not yet compiled. + // The result is cached for the lifetime of the application. + _ = dbContext.Model; + + await uow.CompleteAsync(); + _logger.LogInformation("[DbWarmup] Phase 1 complete — EF Core model compiled."); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (Exception ex) + { + _logger.LogWarning(ex, "[DbWarmup] Phase 1 (model compilation) failed — this is unexpected."); + } + } + + // Step 2: Per-tenant DB connection + PostgreSQL query plan warmup + // Enumerates all tenants (ITenantRepository -> GrantManagerDbContext -> accessible without an active tenant scope). + // Foreach tenant, opens a new DI scope, activates the tenant via ICurrentTenant.Change, and issues a Take(1) query so that: + // - Opens and pools a connection to that tenant's database + // - PostgreSQL parses and caches the parameterised execution plan for the query shape + // - EFCore's compiled query cache is populated for this tenant + // Each tenant is isolated in its own scope to prevent UoW state from leaking between tenants. + IReadOnlyList tenants; + + using (var tenantListScope = _scopeFactory.CreateScope()) + { + var tenantUowManager = tenantListScope.ServiceProvider.GetRequiredService(); + try + { + using var uow = tenantUowManager.Begin(requiresNew: true, isTransactional: false); + var tenantRepository = tenantListScope.ServiceProvider.GetRequiredService(); + tenants = await tenantRepository.GetListAsync(); + await uow.CompleteAsync(); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (Exception ex) + { + _logger.LogWarning(ex, "[DbWarmup] Phase 2 — could not retrieve tenant list from host database. Skipping per-tenant warmup."); + return; + } + } + + if (tenants.Count == 0) + { + _logger.LogDebug("[DbWarmup] Phase 2 — no tenants found in host database. Skipping per-tenant DB warmup."); + return; + } + + _logger.LogInformation("[DbWarmup] Phase 2 — warming {TenantCount} tenant(s).", tenants.Count); + + var warmed = 0; + foreach (var tenant in tenants) + { + if (stoppingToken.IsCancellationRequested) return; + + using var tenantScope = _scopeFactory.CreateScope(); + var currentTenant = tenantScope.ServiceProvider.GetRequiredService(); + var tenantUowManager = tenantScope.ServiceProvider.GetRequiredService(); + + using (currentTenant.Change(tenant.Id)) + { + try + { + using var uow = tenantUowManager.Begin(requiresNew: true, isTransactional: false); + var repository = tenantScope.ServiceProvider.GetRequiredService(); + + await repository.WithFullDetailsAsync( + skipCount: 0, + maxResultCount: 1, + sorting: null, + submittedFromDate: DateTime.UtcNow.AddMonths(-6), + submittedToDate: DateTime.UtcNow); + + await uow.CompleteAsync(); + warmed++; + _logger.LogDebug("[DbWarmup] Tenant '{TenantName}' ({TenantId}) warmed.", tenant.Name, tenant.Id); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (Exception ex) + { + _logger.LogDebug(ex, + "[DbWarmup] Tenant '{TenantName}' ({TenantId}) — DB round-trip skipped. " + + "Tenant database may not be accessible in this environment.", + tenant.Name, tenant.Id); + } + } + } + + _logger.LogInformation("[DbWarmup] Phase 2 complete — {Warmed}/{Total} tenant(s) warmed.", warmed, tenants.Count); + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index e6f9d5eb1..54117ff41 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -135,6 +135,9 @@ public override void ConfigureServices(ServiceConfigurationContext context) var hostingEnvironment = context.Services.GetHostingEnvironment(); var configuration = context.Services.GetConfiguration(); + // Pre-warm the EF Core query pipeline after startup (web host only, not DbMigrator) + context.Services.AddHostedService(); + ConfgureFormsApiAuhentication(context); ConfigureAuthentication(context, configuration); ConfigurePolicies(context); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index e039b477e..efa2dff22 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -9,6 +9,8 @@ $(function () { const l = abp.localization.getResource('GrantManager'); const defaultQuickDateRange = 'last6months'; const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const canViewApplicants = abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList'); + const dtTextRenderer = $.fn.dataTable.render.text(); let dt = $('#GrantApplicationsTable'); let dataTable; @@ -89,6 +91,7 @@ $(function () { } }) dt.colReorder.order(orderedIndexes); + dt.columns.adjust(); if (typeof dt.filterRow === 'function') { const filterRowApi = dt.filterRow(); @@ -168,13 +171,15 @@ $(function () { }; let formatItems = function (items) { - const newData = items.map((item, index) => { - return { - ...item, - rowCount: index - }; + // Previously used + // const newData = items.map((item, index) => { return { ...item, rowCount: index }; }); + // return newdata; + // While in clientside mode, we're always retrieving the full dataset. + // Can be reverted for server-side + items.forEach((item, index) => { + item.rowCount = index; }); - return newData; + return items; } init(); @@ -350,12 +355,10 @@ $(function () { UIElements.quickDateRange.val('custom'); localStorage.setItem('GrantApplications_QuickRange', 'custom'); - const dtInstance = $('#GrantApplicationsTable').DataTable(); - localStorage.setItem("GrantApplications_FromDate", grantTableFilters.submittedFromDate); localStorage.setItem("GrantApplications_ToDate", grantTableFilters.submittedToDate); - dtInstance.ajax.reload(null, true); + dataTable.ajax.reload(null, true); } function handleQuickDateRangeChange() { const selectedRange = $(this).val(); @@ -375,8 +378,7 @@ $(function () { setDateRangeLocalStorage(selectedRange, range); // Reload the table with new filters - const dtInstance = $('#GrantApplicationsTable').DataTable(); - dtInstance.ajax.reload(null, true); + dataTable.ajax.reload(null, true); } function initializeDataTableAndEvents() { @@ -399,6 +401,7 @@ $(function () { }, responseCallback, actionButtons, + deferRender: true, serverSideEnabled: false, pagingEnabled: true, reorderEnabled: true, @@ -463,8 +466,7 @@ $(function () { } $('#search').on('input', function () { - let table = $('#GrantApplicationsTable').DataTable(); - table.search($(this).val()).draw(); + dataTable.search($(this).val()).draw(); }); //For savedStates @@ -574,7 +576,7 @@ $(function () { getNonRegisteredOrganizationNameColumn(columnIndex++), getUnityApplicationIdColumn(columnIndex++), getLinkRelationshipType(columnIndex++), - ].map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })) + ].map((column) => ({ ...column, targets: [column.index] })) .sort((a, b) => a.index - b.index); return sortedColumns; } @@ -589,13 +591,13 @@ $(function () { render: function(data, type, row) { let applicantName = (typeof data !== 'string' || data.trim() === '') ? 'Applicant Name' : data; - if (type === 'sort' || type === 'filter') { + if (type !== 'display') { return applicantName; } - const safeApplicantName = $.fn.dataTable.render.text().display(applicantName); + const safeApplicantName = dtTextRenderer.display(applicantName); - if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) { + if (canViewApplicants) { const applicantId = row?.applicant?.id; const isGuid = applicantId && guidPattern.test(applicantId); @@ -618,6 +620,7 @@ $(function () { name: 'referenceNo', className: 'data-table-header text-nowrap', render: function (data, type, row) { + if (type !== 'display') return data ?? ''; return `${data}`; }, index: columnIndex @@ -711,10 +714,13 @@ $(function () { displayText = getNames(data); } + if (type !== 'display') return displayText.trim(); + + const tooltipText = data?.length ? getNames(data) : ''; return ` ' + displayText + '' + + + tooltipText + '">' + displayText + '' + ``; }, index: columnIndex @@ -835,10 +841,8 @@ $(function () { name: 'projectStartDate', data: 'projectStartDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -850,10 +854,8 @@ $(function () { name: 'projectEndDate', data: 'projectEndDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -1057,11 +1059,10 @@ $(function () { data: 'applicationTag', className: '', render: function (data) { - - let tagNames = data - .filter(x => x?.tag?.name) - .map(x => x.tag.name); - return tagNames.join(', ') ?? ''; + return data + .filter(x => x?.tag?.name) + .map(x => x.tag.name) + .join(', '); }, index: columnIndex } @@ -1117,10 +1118,8 @@ $(function () { name: 'dueDate', data: 'dueDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -1145,10 +1144,8 @@ $(function () { name: 'finalDecisionDate', data: 'finalDecisionDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -1238,7 +1235,10 @@ $(function () { name: 'applicationLinks', data: 'applicationLinks', className: 'data-table-header', - render: function (data) { + render: function (data, type) { + if (type !== 'display' && type !== 'fullName') { + return (data || []).filter(x => x?.linkType).map(x => x.linkType).join(', '); + } const linkNames = Array.from(new Set((data || []) .filter(x => x?.linkType) .map(x => { @@ -1447,9 +1447,6 @@ $(function () { data: 'notes', className: 'data-table-header multi-line', width: "20rem", - createdCell: function (td) { - $(td).css('min-width', '20rem'); - }, render: function (data) { return data ?? ''; }, @@ -1543,50 +1540,47 @@ $(function () { return data.duty ? (" [" + data.duty + "]") : ''; } + const _companyTypeMap = new Map([ + ['BC', 'BC Company'], + ['CP', 'Cooperative'], + ['GP', 'General Partnership'], + ['S', 'Society'], + ['SP', 'Sole Proprietorship'], + ['A', 'Extraprovincial Company'], + ['B', 'Extraprovincial'], + ['BEN', 'Benefit Company'], + ['C', 'Continuation In'], + ['CC', 'BC Community Contribution Company'], + ['CS', 'Continued In Society'], + ['CUL', 'Continuation In as a BC ULC'], + ['EPR', 'Extraprovincial Registration'], + ['FI', 'Financial Institution'], + ['FOR', 'Foreign Registration'], + ['LIB', 'Public Library Association'], + ['LIC', 'Licensed (Extra-Pro)'], + ['LL', 'Limited Liability Partnership'], + ['LLC', 'Limited Liability Company'], + ['LP', 'Limited Partnership'], + ['MF', 'Miscellaneous Firm'], + ['PA', 'Private Act'], + ['PAR', 'Parish'], + ['QA', 'CO 1860'], + ['QB', 'CO 1862'], + ['QC', 'CO 1878'], + ['QD', 'CO 1890'], + ['QE', 'CO 1897'], + ['REG', 'Registraton (Extra-pro)'], + ['ULC', 'BC Unlimited Liability Company'], + ['XCP', 'Extraprovincial Cooperative'], + ['XL', 'Extrapro Limited Liability Partnership'], + ['XP', 'Extraprovincial Limited Partnership'], + ['XS', 'Extraprovincial Society'] + ]); + function getFullType(code) { - const companyTypes = [ - { code: "BC", name: "BC Company" }, - { code: "CP", name: "Cooperative" }, - { code: "GP", name: "General Partnership" }, - { code: "S", name: "Society" }, - { code: "SP", name: "Sole Proprietorship" }, - { code: "A", name: "Extraprovincial Company" }, - { code: "B", name: "Extraprovincial" }, - { code: "BEN", name: "Benefit Company" }, - { code: "C", name: "Continuation In" }, - { code: "CC", name: "BC Community Contribution Company" }, - { code: "CS", name: "Continued In Society" }, - { code: "CUL", name: "Continuation In as a BC ULC" }, - { code: "EPR", name: "Extraprovincial Registration" }, - { code: "FI", name: "Financial Institution" }, - { code: "FOR", name: "Foreign Registration" }, - { code: "LIB", name: "Public Library Association" }, - { code: "LIC", name: "Licensed (Extra-Pro)" }, - { code: "LL", name: "Limited Liability Partnership" }, - { code: "LLC", name: "Limited Liability Company" }, - { code: "LP", name: "Limited Partnership" }, - { code: "MF", name: "Miscellaneous Firm" }, - { code: "PA", name: "Private Act" }, - { code: "PAR", name: "Parish" }, - { code: "QA", name: "CO 1860" }, - { code: "QB", name: "CO 1862" }, - { code: "QC", name: "CO 1878" }, - { code: "QD", name: "CO 1890" }, - { code: "QE", name: "CO 1897" }, - { code: "REG", name: "Registraton (Extra-pro)" }, - { code: "ULC", name: "BC Unlimited Liability Company" }, - { code: "XCP", name: "Extraprovincial Cooperative" }, - { code: "XL", name: "Extrapro Limited Liability Partnership" }, - { code: "XP", name: "Extraprovincial Limited Partnership" }, - { code: "XS", name: "Extraprovincial Society" } - ]; - const match = companyTypes.find(entry => entry.code === code); - return match ? match.name : "Unknown"; - } - - - window.addEventListener('resize', () => { - }); + return _companyTypeMap.get(code) ?? 'Unknown'; + } + PubSub.subscribe( 'refresh_application_list', @@ -1598,22 +1592,17 @@ $(function () { ); function getNames(data) { - let name = ''; - data.forEach((d, index) => { - name = name + (' ' + d.fullName + getDutyText(d)); - if (index != (data.length - 1)) { - name = name + ','; - } - }); - - return name; + return data.map(d => d.fullName + getDutyText(d)).join(', '); } + const _titleCaseCache = new Map(); function titleCase(str) { - str = str.toLowerCase().split(' '); - for (let i = 0; i < str.length; i++) { - str[i] = str[i].charAt(0).toUpperCase() + str[i].slice(1); - } - return str.join(' '); + //This funciton is currently called by 6 columns, all of which are status types or predetermined values + //Caching the results in this case is to improve large data table loads while we're in client side. + //Columns: likelihoodOfFunding, assessmentResult, riskRanking, acquisition, fyeMonth, dueDiligenceStatus + if (_titleCaseCache.has(str)) return _titleCaseCache.get(str); + const result = str.toLowerCase().split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + _titleCaseCache.set(str, result); + return result; } function convertToYesNo(str) { From a014b5a10fd2241dbd5e83c7d5b83ba80ed6fc66 Mon Sep 17 00:00:00 2001 From: David Bright Date: Fri, 8 May 2026 12:04:56 -0700 Subject: [PATCH 18/21] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../GrantManagerDbWarmupService.cs | 21 ++++++++++++++++--- .../Pages/GrantApplications/Index.js | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs index 9823d4bca..4d8065797 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs @@ -40,19 +40,34 @@ public class GrantManagerDbWarmupService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + private readonly IHostApplicationLifetime _hostApplicationLifetime; public GrantManagerDbWarmupService( IServiceScopeFactory scopeFactory, - ILogger logger) + ILogger logger, + IHostApplicationLifetime hostApplicationLifetime) { _scopeFactory = scopeFactory; _logger = logger; + _hostApplicationLifetime = hostApplicationLifetime; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - // Allow ABP's OnApplicationInitialization and any module bootstrapping to fully complete before issuing queries. - await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + // Wait until the host has fully started so ABP module initialization and startup hooks + // are complete before issuing any warmup queries. + if (!_hostApplicationLifetime.ApplicationStarted.IsCancellationRequested) + { + var applicationStartedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var applicationStartedRegistration = _hostApplicationLifetime.ApplicationStarted.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(), + applicationStartedTcs); + using var cancellationRegistration = stoppingToken.Register( + static state => ((TaskCompletionSource)state!).TrySetCanceled(), + applicationStartedTcs); + + await applicationStartedTcs.Task; + } if (stoppingToken.IsCancellationRequested) return; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 2555b6a30..7c10cc14d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -173,7 +173,7 @@ $(function () { let formatItems = function (items) { // Previously used // const newData = items.map((item, index) => { return { ...item, rowCount: index }; }); - // return newdata; + // return newData; // While in clientside mode, we're always retrieving the full dataset. // Can be reverted for server-side items.forEach((item, index) => { From 110e110f8340341299064b4600e24f15314c9ab4 Mon Sep 17 00:00:00 2001 From: David Bright Date: Fri, 8 May 2026 13:02:40 -0700 Subject: [PATCH 19/21] As per Copilot flag, added configurable DB warmup: options, limits, and timeouts src\Unity.GrantManager.EntityFrameworkCore\EntityFrameworkCore\DbWarmupOptions.cs Note: Phase 1: EF Core model compliation Phase 2: per-tenant DB query warming. AppSettings.json can now include the options: "DbWarmup": { "IsPhase2Enabled": Default true; "MaxTenants": 0 means no limit. Default: 0. "Phase2TimeoutSeconds": 0 means no timeout. Default: 0. Total seconds allowed for Phase 2 across all tenants before it is abandoned. } --- .../EntityFrameworkCore/DbWarmupOptions.cs | 40 +++++++++++ .../GrantManagerDbWarmupService.cs | 71 +++++++++++++++---- .../GrantManagerWebModule.cs | 1 + 3 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs new file mode 100644 index 000000000..04aa7a77b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs @@ -0,0 +1,40 @@ +namespace Unity.GrantManager.EntityFrameworkCore; + +/// +/// Configuration options for . +/// Bind from appsettings.json under the "DbWarmup" section. +/// +/// Example: +/// +/// "DbWarmup": { +/// "IsPhase2Enabled": true, +/// "MaxTenants": 5, +/// "Phase2TimeoutSeconds": 30 +/// } +/// +/// +public class DbWarmupOptions +{ + public const string SectionName = "DbWarmup"; + + /// + /// When false, Phase 2 (per-tenant DB round-trips) is skipped entirely. + /// Phase 1 (EF Core model compilation) always runs regardless of this setting. + /// Default: true. + /// + public bool IsPhase2Enabled { get; set; } = true; + + /// + /// Maximum number of tenants to warm in Phase 2. + /// 0 means no limit. Default: 0. + /// Useful in constrained environments or when tenant count is very large. + /// + public int MaxTenants { get; set; } = 0; + + /// + /// Total seconds allowed for Phase 2 across all tenants before it is abandoned. + /// 0 means no timeout. Default: 0. + /// Remaining tenants are skipped gracefully when the timeout elapses. + /// + public int Phase2TimeoutSeconds { get; set; } = 0; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs index 4d8065797..723f7638e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs @@ -1,9 +1,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -26,14 +28,18 @@ namespace Unity.GrantManager.EntityFrameworkCore; /// /// These costs are normally deferred to the first HTTP request, causing 6-8 second cold-start /// latency for the GrantApplications DataTable. This service fires the most expensive query -/// shape (WithFullDetailsAsync with typical date filters) shortly after startup so the cache -/// is warm before any user makes a request. +/// shape (GetApplicationListRecordsAsync with typical date filters) shortly after startup so the +/// cache is warm before any user makes a request. /// /// Warmup is split into two independent phases: /// Phase 1 (model compilation) — always succeeds; no DB connection required. -/// Phase 2 (per-tenant DB round-trip) — iterates every tenant from the host database and -/// warms Npgsql's connection pool and PostgreSQL's query plan cache for each, ensuring no -/// tenant's first user pays the connection-establishment cost. +/// Phase 2 (per-tenant DB round-trip) — iterates tenants from the host database and warms +/// Npgsql's connection pool and PostgreSQL's query plan cache for each. +/// +/// Phase 2 behaviour is configurable via (appsettings "DbWarmup" section): +/// IsPhase2Enabled — set false to skip Phase 2 entirely (default: true). +/// MaxTenants — cap the number of tenants warmed; 0 = unlimited (default: 0). +/// Phase2TimeoutSeconds — abandon Phase 2 after N seconds; 0 = no timeout (default: 0). /// /// public class GrantManagerDbWarmupService : BackgroundService @@ -41,15 +47,18 @@ public class GrantManagerDbWarmupService : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly DbWarmupOptions _options; public GrantManagerDbWarmupService( IServiceScopeFactory scopeFactory, ILogger logger, - IHostApplicationLifetime hostApplicationLifetime) + IHostApplicationLifetime hostApplicationLifetime, + IOptions options) { _scopeFactory = scopeFactory; _logger = logger; _hostApplicationLifetime = hostApplicationLifetime; + _options = options.Value; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -107,6 +116,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // - PostgreSQL parses and caches the parameterised execution plan for the query shape // - EFCore's compiled query cache is populated for this tenant // Each tenant is isolated in its own scope to prevent UoW state from leaking between tenants. + // Uses GetApplicationListRecordsAsync — the same optimized projected query the DataTable endpoint calls. + if (!_options.IsPhase2Enabled) + { + _logger.LogInformation("[DbWarmup] Phase 2 disabled via configuration — skipping per-tenant warmup."); + return; + } + IReadOnlyList tenants; using (var tenantListScope = _scopeFactory.CreateScope()) @@ -133,12 +149,43 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) return; } - _logger.LogInformation("[DbWarmup] Phase 2 — warming {TenantCount} tenant(s).", tenants.Count); + // Apply MaxTenants cap + var tenantsToWarm = _options.MaxTenants > 0 + ? tenants.Take(_options.MaxTenants).ToList() + : (IReadOnlyList)tenants; + + if (_options.MaxTenants > 0 && tenants.Count > _options.MaxTenants) + { + _logger.LogInformation( + "[DbWarmup] Phase 2 — capped at {MaxTenants} of {TotalTenants} tenant(s) (MaxTenants setting).", + _options.MaxTenants, tenants.Count); + } + + _logger.LogInformation("[DbWarmup] Phase 2 — warming {TenantCount} tenant(s).", tenantsToWarm.Count); + + // Apply Phase2TimeoutSeconds — link a deadline token with stoppingToken + using var phase2Cts = _options.Phase2TimeoutSeconds > 0 + ? CancellationTokenSource.CreateLinkedTokenSource(stoppingToken) + : null; + if (phase2Cts != null) + { + phase2Cts.CancelAfter(TimeSpan.FromSeconds(_options.Phase2TimeoutSeconds)); + _logger.LogDebug("[DbWarmup] Phase 2 — timeout set to {Seconds}s.", _options.Phase2TimeoutSeconds); + } + var phase2Token = phase2Cts?.Token ?? stoppingToken; var warmed = 0; - foreach (var tenant in tenants) + foreach (var tenant in tenantsToWarm) { - if (stoppingToken.IsCancellationRequested) return; + if (phase2Token.IsCancellationRequested) + { + // Distinguish between a Phase 2 timeout and a host shutdown + if (!stoppingToken.IsCancellationRequested) + _logger.LogInformation( + "[DbWarmup] Phase 2 — timeout reached after {Warmed}/{Total} tenant(s).", + warmed, tenantsToWarm.Count); + return; + } using var tenantScope = _scopeFactory.CreateScope(); var currentTenant = tenantScope.ServiceProvider.GetRequiredService(); @@ -151,7 +198,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) using var uow = tenantUowManager.Begin(requiresNew: true, isTransactional: false); var repository = tenantScope.ServiceProvider.GetRequiredService(); - await repository.WithFullDetailsAsync( + await repository.GetApplicationListRecordsAsync( skipCount: 0, maxResultCount: 1, sorting: null, @@ -162,7 +209,7 @@ await repository.WithFullDetailsAsync( warmed++; _logger.LogDebug("[DbWarmup] Tenant '{TenantName}' ({TenantId}) warmed.", tenant.Name, tenant.Id); } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (OperationCanceledException) when (phase2Token.IsCancellationRequested) { return; } catch (Exception ex) { _logger.LogDebug(ex, @@ -173,6 +220,6 @@ await repository.WithFullDetailsAsync( } } - _logger.LogInformation("[DbWarmup] Phase 2 complete — {Warmed}/{Total} tenant(s) warmed.", warmed, tenants.Count); + _logger.LogInformation("[DbWarmup] Phase 2 complete — {Warmed}/{Total} tenant(s) warmed.", warmed, tenantsToWarm.Count); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 54117ff41..081fa8dd7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -136,6 +136,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) var configuration = context.Services.GetConfiguration(); // Pre-warm the EF Core query pipeline after startup (web host only, not DbMigrator) + context.Services.Configure(configuration.GetSection(DbWarmupOptions.SectionName)); context.Services.AddHostedService(); ConfgureFormsApiAuhentication(context); From 7fea0409dcb3e7f85415fef16861c7198f95eb78 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 8 May 2026 11:45:42 -0700 Subject: [PATCH 20/21] AB#32543 move AI reporting into AI module --- .../Localization/AI/en.json | 1 + .../Unity.AI.Web/Menus/AIMenuContributor.cs | 21 +++++- .../src/Unity.AI.Web/Menus/AIMenus.cs | 1 + .../Pages/AIReporting/Index.cshtml | 36 ++++++++++ .../Pages/AIReporting/Index.cshtml.cs | 42 +++++++++++ .../Unity.AI.Web/Pages/AIReporting/Index.js | 72 +++++++++++++++++++ .../Index.native.cshtml.cs.disabled} | 2 +- .../AIReporting/Index.native.cshtml.disabled} | 2 +- .../AIReporting/Index.native.css.disabled} | 0 .../AIReporting/Index.native.js.disabled} | 0 .../Unity.AI.Web/Pages/AIReporting/README.md | 18 +++++ .../Localization/GrantManager/en.json | 1 - .../Menus/GrantManagerMenuContributor.cs | 18 ----- .../Menus/GrantManagerMenus.cs | 1 - 14 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.js rename applications/Unity.GrantManager/{src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs => modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.cs.disabled} (95%) rename applications/Unity.GrantManager/{src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml => modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.disabled} (99%) rename applications/Unity.GrantManager/{src/Unity.GrantManager.Web/Pages/AIReporting/Index.css => modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.css.disabled} (100%) rename applications/Unity.GrantManager/{src/Unity.GrantManager.Web/Pages/AIReporting/Index.js => modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.js.disabled} (100%) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/README.md diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index f04da0763..d53cd969c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -15,6 +15,7 @@ "Permission:AI.Prompts.Create": "Create Prompts", "Permission:AI.Prompts.Update": "Edit Prompts", "Permission:AI.Prompts.Delete": "Delete Prompts", + "Menu:AIReporting": "AI Reporting", "Setting:AI.AutomaticGenerationEnabled": "Automatically Generate AI Analysis", "Setting:AI.ManualGenerationEnabled": "Manually Initiate AI Analysis", diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs index 453ad3549..bf7f06d89 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs @@ -1,5 +1,9 @@ using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Unity.AI.Localization; +using Unity.AI.Permissions; using Unity.Modules.Shared.Permissions; +using Volo.Abp.Features; using Volo.Abp.UI.Navigation; namespace Unity.AI.Web.Menus; @@ -14,8 +18,11 @@ public async Task ConfigureMenuAsync(MenuConfigurationContext context) } } - private static Task ConfigureMainMenuAsync(MenuConfigurationContext context) + private static async Task ConfigureMainMenuAsync(MenuConfigurationContext context) { + var l = context.GetLocalizer(); + var featureChecker = context.ServiceProvider.GetRequiredService(); + context.Menu.AddItem(new ApplicationMenuItem( name: AIMenus.Prompts, displayName: "AI Prompts", @@ -25,6 +32,16 @@ private static Task ConfigureMainMenuAsync(MenuConfigurationContext context) requiredPermissionName: IdentityConsts.ITOperationsPermissionName )); - return Task.CompletedTask; + if (await featureChecker.IsEnabledAsync("Unity.AIReporting")) + { + context.Menu.AddItem(new ApplicationMenuItem( + name: AIMenus.Reporting, + displayName: l["Menu:AIReporting"], + url: "~/AIReporting", + icon: "fl fl-view-dashboard", + order: 9, + requiredPermissionName: AIPermissions.Reporting.ReportingDefault + )); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs index f93517efe..c455ea168 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs @@ -5,4 +5,5 @@ public static class AIMenus private const string Prefix = "AI"; public const string Prompts = Prefix + ".Prompts"; + public const string Reporting = Prefix + ".Reporting"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml new file mode 100644 index 000000000..a7ffb565f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml @@ -0,0 +1,36 @@ +@page +@model Unity.AI.Web.Pages.AIReporting.IndexModel + +@section styles { + @if (Model.CanViewAiReporting) + { + + } +} + +@section scripts { + @if (Model.CanViewAiReporting) + { + + + + + } +} + +@if (Model.CanViewAiReporting) +{ + + +
+} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml.cs new file mode 100644 index 000000000..6d8259c9f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Integrations; +using Unity.Modules.Shared.Permissions; +using Volo.Abp; +using Volo.Abp.Features; + +namespace Unity.AI.Web.Pages.AIReporting +{ + public class IndexModel( + IEndpointManagementAppService endpointManagementAppService, + IFeatureChecker featureChecker, + IAuthorizationService authorizationService, + ILogger logger) : PageModel + { + public bool CanViewAiReporting { get; private set; } + public string ReportingAiUrl { get; private set; } = string.Empty; + + public async Task OnGetAsync() + { + CanViewAiReporting = await featureChecker.IsEnabledAsync("Unity.AIReporting") + || (await authorizationService.AuthorizeAsync(User, IdentityConsts.ITAdminPolicyName)).Succeeded; + + if (!CanViewAiReporting) + { + return; + } + + try + { + ReportingAiUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.REPORTING_AI); + } + catch (UserFriendlyException ex) + { + logger.LogWarning(ex, "AI Reporting endpoint is not configured."); + ReportingAiUrl = string.Empty; + } + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.js new file mode 100644 index 000000000..a8b364e85 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.js @@ -0,0 +1,72 @@ +const showInitializationError = (container, message, error) => { + console.error(message, error); + container.textContent = message; +}; +const reportingAiUrl = globalThis.reportingAiUrl; +const container = document.getElementById('container'); + +const initializeAIReporting = async () => { + if (!container) { + return; + } + + if (!reportingAiUrl) { + showInitializationError(container, 'AI Reporting is not configured.'); + return; + } + + let reportingUrl; + try { + reportingUrl = new URL(reportingAiUrl); + } catch (error) { + showInitializationError(container, 'AI Reporting is not configured correctly.', error); + return; + } + + let token; + try { + token = await unity.grantManager.identity.jwtToken.generateJWTToken(); + } catch (error) { + showInitializationError(container, 'Failed to initialize AI Reporting. Please refresh the page and try again.', error); + return; + } + + const iframe = document.createElement('iframe'); + + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + const targetOrigin = reportingUrl.origin; + + const messageHandler = (event) => { + if (event.origin !== targetOrigin) { + return; + } + + if (event.data?.type === 'READY') { + try { + iframe.contentWindow.postMessage( + { type: 'AUTH_TOKEN', token }, + targetOrigin + ); + } catch (error) { + console.error('Failed to send authentication token to AI Reporting iframe:', error); + } + + globalThis.removeEventListener('message', messageHandler); + } + }; + + globalThis.addEventListener('message', messageHandler); + + iframe.onerror = () => { + console.error('Failed to load AI Reporting iframe'); + globalThis.removeEventListener('message', messageHandler); + }; + + iframe.src = reportingUrl.href; + container.appendChild(iframe); +}; + +initializeAIReporting(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.cs.disabled similarity index 95% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.cs.disabled index 9b37015de..a5bb9d4e4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.cs.disabled @@ -5,7 +5,7 @@ using Unity.Modules.Shared.Permissions; using Volo.Abp.Features; -namespace Unity.GrantManager.Web.Pages.AIReporting +namespace Unity.AI.Web.Pages.AIReporting { public class IndexModel( IEndpointManagementAppService endpointManagementAppService, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.disabled similarity index 99% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.disabled index 6579b5967..3d3de4406 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.cshtml.disabled @@ -1,5 +1,5 @@ @page -@model Unity.GrantManager.Web.Pages.AIReporting.IndexModel +@model Unity.AI.Web.Pages.AIReporting.IndexModel @section styles { @if (Model.CanViewAiReporting) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.css.disabled similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.css.disabled diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.js.disabled similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.native.js.disabled diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/README.md b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/README.md new file mode 100644 index 000000000..2082a91fd --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/README.md @@ -0,0 +1,18 @@ +# AI Reporting Page + +`Index.*` currently uses the iframe-hosted AI Reporting app. + +The converted native Razor implementation is parked beside it as: + +- `Index.native.cshtml.disabled` +- `Index.native.cshtml.cs.disabled` +- `Index.native.css.disabled` +- `Index.native.js.disabled` + +To switch back to the native implementation: + +1. Replace `Index.cshtml` with `Index.native.cshtml.disabled`. +2. Replace `Index.cshtml.cs` with `Index.native.cshtml.cs.disabled`. +3. Replace `Index.js` with `Index.native.js.disabled`. +4. Rename `Index.native.css.disabled` to `Index.css`. +5. Build `Unity.AI.Web` and `Unity.GrantManager.Web`. 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 ce7fb443f..5a2dfc545 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 @@ -3,7 +3,6 @@ "texts": { "Menu:Home": "Home", "Menu:Dashboard": "Dashboard", - "Menu:AIReporting": "AI Reporting", "Menu:GrantPrograms": "Grant Programs", "Menu:GrantTracker": "Grant Tracker", "Menu:Applications": "Applications", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs index f5fe33bf9..620938fe6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs @@ -1,13 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; using System.Threading.Tasks; -using Unity.AI.Permissions; using Unity.GrantManager.Localization; using Unity.GrantManager.Permissions; using Unity.Identity.Web.Navigation; using Unity.Modules.Shared.Permissions; using Unity.TenantManagement; using Unity.TenantManagement.Web.Navigation; -using Volo.Abp.Features; using Volo.Abp.Identity; using Volo.Abp.UI.Navigation; @@ -29,7 +26,6 @@ public async Task ConfigureMenuAsync(MenuConfigurationContext context) private async static Task ConfigureMainMenuAsync(MenuConfigurationContext context) { var l = context.GetLocalizer(); - var featureChecker = context.ServiceProvider.GetRequiredService(); context.Menu.AddItem( new ApplicationMenuItem( @@ -117,20 +113,6 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex ) ); - if (await featureChecker.IsEnabledAsync("Unity.AIReporting")) - { - context.Menu.AddItem( - new ApplicationMenuItem( - GrantManagerMenus.AIReporting, - l["Menu:AIReporting"], - "~/AIReporting", - icon: "fl fl-view-dashboard", - requiredPermissionName: AIPermissions.Reporting.ReportingDefault, - order: 9 - ) - ); - } - // ******************** // Admin - Tenant Management context.Menu.AddItem( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenus.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenus.cs index 872b27f47..f5fa7db19 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenus.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenus.cs @@ -17,7 +17,6 @@ public static class GrantManagerMenus public const string Intakes = Prefix + ".Intakes"; public const string ApplicationForms = Prefix + ".ApplicationForms"; public const string EndpointManagement = Prefix + ".EndpointManagement"; - public const string AIReporting = Prefix + ".AIReporting"; public const string Applicants = Prefix + ".Applicants"; public const string ConfigurationManagement = Prefix + ".ConfigurationManagement"; public const string UnityAdmin = Prefix + ".UnityAdmin"; From d655cba46d851cc799e27dd50b98045e86b68a17 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 11:23:50 -0700 Subject: [PATCH 21/21] AB#32900 make AI generation runtime cancellation-ready --- .../AI/Extraction/ITextExtractionService.cs | 3 +- .../AI/IAIService.cs | 7 +- .../AI/Extraction/TextExtractionService.cs | 84 +++++++++++++------ .../Operations/ApplicationAnalysisService.cs | 5 +- .../Operations/ApplicationScoringService.cs | 25 ++++-- .../AI/Operations/AttachmentSummaryService.cs | 47 +++++++---- .../Operations/IApplicationAnalysisService.cs | 3 +- .../Operations/IApplicationScoringService.cs | 3 +- .../Operations/IAttachmentSummaryService.cs | 7 +- .../AI/Runtime/OpenAIRuntimeService.cs | 65 +++++++++----- .../AI/Runtime/OpenAITransportService.cs | 12 ++- .../AttachmentSummaryServiceTests.cs | 45 ++++++++-- 12 files changed, 217 insertions(+), 89 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Extraction/ITextExtractionService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Extraction/ITextExtractionService.cs index 5ad933602..5db96abd7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Extraction/ITextExtractionService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Extraction/ITextExtractionService.cs @@ -1,10 +1,11 @@ using System.IO; +using System.Threading; using System.Threading.Tasks; namespace Unity.AI.Extraction { public interface ITextExtractionService { - Task ExtractTextAsync(string fileName, Stream fileContent, string contentType); + Task ExtractTextAsync(string fileName, Stream fileContent, string contentType, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index be06cec5a..0606899c8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; using Unity.AI.Requests; using Unity.AI.Responses; @@ -8,8 +9,8 @@ public interface IAIService { Task IsAvailableAsync(); - Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request); - Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request); - Task GenerateApplicationScoringAsync(ApplicationScoringRequest request); + Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request, CancellationToken cancellationToken = default); + Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default); + Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs index e7490cfac..1da60206a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using UglyToad.PdfPig; @@ -32,7 +33,7 @@ public TextExtractionService(ILogger logger) _logger = logger; } - public Task ExtractTextAsync(string fileName, Stream fileContent, string contentType) + public Task ExtractTextAsync(string fileName, Stream fileContent, string contentType, CancellationToken cancellationToken = default) { if (fileContent == null) { @@ -42,6 +43,7 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string try { + cancellationToken.ThrowIfCancellationRequested(); var normalizedContentType = contentType?.ToLowerInvariant() ?? string.Empty; var extension = Path.GetExtension(fileName)?.ToLowerInvariant() ?? string.Empty; @@ -53,12 +55,12 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string var rawText = extension switch { - ".txt" or ".csv" or ".json" or ".xml" => ExtractTextFromTextFile(fileContent), - ".pdf" => ExtractTextFromPdfFile(fileName, fileContent), - ".docx" => ExtractTextFromWordDocx(fileName, fileContent), - ".xls" or ".xlsx" => ExtractTextFromExcelFile(fileName, fileContent), - ".pptx" => ExtractTextFromPowerPointFile(fileName, fileContent), - _ => ExtractByContentType(fileName, fileContent, normalizedContentType) + ".txt" or ".csv" or ".json" or ".xml" => ExtractTextFromTextFile(fileContent, cancellationToken), + ".pdf" => ExtractTextFromPdfFile(fileName, fileContent, cancellationToken), + ".docx" => ExtractTextFromWordDocx(fileName, fileContent, cancellationToken), + ".xls" or ".xlsx" => ExtractTextFromExcelFile(fileName, fileContent, cancellationToken), + ".pptx" => ExtractTextFromPowerPointFile(fileName, fileContent, cancellationToken), + _ => ExtractByContentType(fileName, fileContent, normalizedContentType, cancellationToken) }; if (string.IsNullOrEmpty(rawText)) @@ -69,6 +71,10 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { _logger.LogError(ex, "Error extracting text from {FileName}", fileName); @@ -76,29 +82,33 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string } } - private string ExtractByContentType(string fileName, Stream fileContent, string normalizedContentType) + private string ExtractByContentType( + string fileName, + Stream fileContent, + string normalizedContentType, + CancellationToken cancellationToken) { if (normalizedContentType.Contains("text/")) { - return ExtractTextFromTextFile(fileContent); + return ExtractTextFromTextFile(fileContent, cancellationToken); } if (normalizedContentType.Contains("pdf")) { - return ExtractTextFromPdfFile(fileName, fileContent); + return ExtractTextFromPdfFile(fileName, fileContent, cancellationToken); } if (normalizedContentType.Contains("word") || normalizedContentType.Contains("msword") || normalizedContentType.Contains("officedocument.wordprocessingml")) { - return ExtractTextFromWordDocx(fileName, fileContent); + return ExtractTextFromWordDocx(fileName, fileContent, cancellationToken); } if (normalizedContentType.Contains("excel") || normalizedContentType.Contains("spreadsheet")) { - return ExtractTextFromExcelFile(fileName, fileContent); + return ExtractTextFromExcelFile(fileName, fileContent, cancellationToken); } if (normalizedContentType.Contains("presentation") || normalizedContentType.Contains("powerpoint")) { - return ExtractTextFromPowerPointFile(fileName, fileContent); + return ExtractTextFromPowerPointFile(fileName, fileContent, cancellationToken); } return string.Empty; } @@ -111,7 +121,7 @@ private static void RewindIfPossible(Stream stream) } } - private string ExtractTextFromTextFile(Stream fileContent) + private string ExtractTextFromTextFile(Stream fileContent, CancellationToken cancellationToken) { try { @@ -122,6 +132,7 @@ private string ExtractTextFromTextFile(Stream fileContent) int read; while ((read = reader.Read(buffer, 0, buffer.Length)) > 0) { + cancellationToken.ThrowIfCancellationRequested(); var remaining = MaxExtractedTextLength - builder.Length; if (remaining <= 0) break; builder.Append(buffer, 0, Math.Min(read, remaining)); @@ -142,7 +153,7 @@ private string ExtractTextFromTextFile(Stream fileContent) } } - private string ExtractTextFromPdfFile(string fileName, Stream fileContent) + private string ExtractTextFromPdfFile(string fileName, Stream fileContent, CancellationToken cancellationToken) { try { @@ -156,6 +167,7 @@ private string ExtractTextFromPdfFile(string fileName, Stream fileContent) foreach (var pageText in pageTexts) { + cancellationToken.ThrowIfCancellationRequested(); if (builder.Length >= MaxExtractedTextLength) { break; @@ -178,15 +190,15 @@ private string ExtractTextFromPdfFile(string fileName, Stream fileContent) } } - private string ExtractTextFromWordDocx(string fileName, Stream fileContent) + private string ExtractTextFromWordDocx(string fileName, Stream fileContent, CancellationToken cancellationToken) { try { RewindIfPossible(fileContent); using var document = new XWPFDocument(fileContent); var builder = new StringBuilder(); - var processedParagraphCount = AppendDocxParagraphText(document, builder); - var processedTableRowCount = AppendDocxTableText(document, builder); + var processedParagraphCount = AppendDocxParagraphText(document, builder, cancellationToken); + var processedTableRowCount = AppendDocxTableText(document, builder, cancellationToken); _logger.LogDebug( "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", @@ -202,7 +214,10 @@ private string ExtractTextFromWordDocx(string fileName, Stream fileContent) } } - private static int AppendDocxParagraphText(XWPFDocument document, StringBuilder builder) + private static int AppendDocxParagraphText( + XWPFDocument document, + StringBuilder builder, + CancellationToken cancellationToken) { var processedParagraphCount = 0; var paragraphTexts = document.Paragraphs @@ -212,6 +227,7 @@ private static int AppendDocxParagraphText(XWPFDocument document, StringBuilder foreach (var paragraphText in paragraphTexts) { + cancellationToken.ThrowIfCancellationRequested(); if (builder.Length >= MaxExtractedTextLength) { break; @@ -227,7 +243,10 @@ private static int AppendDocxParagraphText(XWPFDocument document, StringBuilder return processedParagraphCount; } - private static int AppendDocxTableText(XWPFDocument document, StringBuilder builder) + private static int AppendDocxTableText( + XWPFDocument document, + StringBuilder builder, + CancellationToken cancellationToken) { if (builder.Length >= MaxExtractedTextLength) { @@ -237,8 +256,10 @@ private static int AppendDocxTableText(XWPFDocument document, StringBuilder buil var processedTableRowCount = 0; foreach (var table in document.Tables) { + cancellationToken.ThrowIfCancellationRequested(); foreach (var row in table.Rows.Take(MaxDocxTableRows)) { + cancellationToken.ThrowIfCancellationRequested(); if (builder.Length >= MaxExtractedTextLength) { return processedTableRowCount; @@ -252,6 +273,7 @@ private static int AppendDocxTableText(XWPFDocument document, StringBuilder buil var rowHadValue = false; foreach (var cellText in cellTexts) { + cancellationToken.ThrowIfCancellationRequested(); rowHadValue = true; if (TryAppendWithTrailingNewline(builder, cellText)) { @@ -269,7 +291,7 @@ private static int AppendDocxTableText(XWPFDocument document, StringBuilder buil return processedTableRowCount; } - private string ExtractTextFromExcelFile(string fileName, Stream fileContent) + private string ExtractTextFromExcelFile(string fileName, Stream fileContent, CancellationToken cancellationToken) { try { @@ -282,13 +304,14 @@ private string ExtractTextFromExcelFile(string fileName, Stream fileContent) for (var sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) { + cancellationToken.ThrowIfCancellationRequested(); if (builder.Length >= MaxExtractedTextLength) { break; } var sheet = workbook.GetSheetAt(sheetIndex); - var (rowsProcessed, limitReached) = TryAppendExcelSheet(sheet, builder); + var (rowsProcessed, limitReached) = TryAppendExcelSheet(sheet, builder, cancellationToken); if (rowsProcessed > 0) { processedSheetCount++; @@ -315,7 +338,7 @@ private string ExtractTextFromExcelFile(string fileName, Stream fileContent) } } - private string ExtractTextFromPowerPointFile(string fileName, Stream fileContent) + private string ExtractTextFromPowerPointFile(string fileName, Stream fileContent, CancellationToken cancellationToken) { try { @@ -328,6 +351,7 @@ private string ExtractTextFromPowerPointFile(string fileName, Stream fileContent foreach (var slideEntry in slideEntries) { + cancellationToken.ThrowIfCancellationRequested(); if (builder.Length >= MaxExtractedTextLength) { break; @@ -398,7 +422,10 @@ private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive return orderedEntries; } - private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) + private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet( + ISheet? sheet, + StringBuilder builder, + CancellationToken cancellationToken) { if (sheet == null) { @@ -408,12 +435,13 @@ private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet(ISheet var processedRows = 0; foreach (IRow row in sheet) { + cancellationToken.ThrowIfCancellationRequested(); if (processedRows >= MaxExcelRowsPerSheet || builder.Length >= MaxExtractedTextLength) { break; } - var (rowHadValue, limitReached) = TryAppendExcelRow(row, builder); + var (rowHadValue, limitReached) = TryAppendExcelRow(row, builder, cancellationToken); if (rowHadValue) { processedRows++; @@ -428,11 +456,15 @@ private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet(ISheet return (processedRows, builder.Length >= MaxExtractedTextLength); } - private static (bool RowHadValue, bool LimitReached) TryAppendExcelRow(IRow row, StringBuilder builder) + private static (bool RowHadValue, bool LimitReached) TryAppendExcelRow( + IRow row, + StringBuilder builder, + CancellationToken cancellationToken) { var rowHasValue = false; foreach (var cell in row.Cells.Take(MaxExcelCellsPerRow)) { + cancellationToken.ThrowIfCancellationRequested(); var value = GetCellText(cell); if (string.IsNullOrWhiteSpace(value)) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs index 1e216650f..12b9b2434 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Unity.AI.Models; using Unity.AI.Prompts; @@ -28,7 +29,7 @@ public class ApplicationAnalysisService( "applicantAgent" }; - public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null) + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { var application = await applicationRepository.GetAsync(applicationId); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); @@ -56,7 +57,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger), Attachments = attachmentSummaries, PromptVersion = promptVersion, - }); + }, cancellationToken); var analysisJson = JsonSerializer.Serialize(analysis, AIJsonDefaults.Indented); application.AIAnalysis = analysisJson; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index 64fee716a..f6188f689 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Unity.Flex.Domain.Scoresheets; using Unity.AI.Models; @@ -25,7 +26,7 @@ public class ApplicationScoringService( AIExecutionModeResolver executionModeResolver, ILogger logger) : IApplicationScoringService, ITransientDependency { - public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null) + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { var application = await applicationRepository.GetAsync(applicationId); var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); @@ -60,8 +61,8 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var perSectionResults = await AIExecutionStrategy.RunAsync( sections, mode, - section => ProcessSectionAsync(applicationId, section, promptData, attachmentSummaries, promptVersion), - batch => ProcessSectionsAsync(applicationId, batch, promptData, attachmentSummaries, promptVersion)); + section => ProcessSectionAsync(applicationId, section, promptData, attachmentSummaries, promptVersion, cancellationToken), + batch => ProcessSectionsAsync(applicationId, batch, promptData, attachmentSummaries, promptVersion, cancellationToken)); var allSectionResults = new Dictionary(); foreach (var sectionResult in perSectionResults) @@ -84,7 +85,8 @@ private async Task> ProcessSectionAsync( ScoresheetSection section, JsonElement promptData, List attachmentSummaries, - string? promptVersion) + string? promptVersion, + CancellationToken cancellationToken) { var sectionResults = new Dictionary(); try @@ -97,13 +99,17 @@ private async Task> ProcessSectionAsync( SectionSchema = JsonSerializer.SerializeToElement(BuildSectionQuestionsData(section), AIJsonDefaults.IndentedCamelCase), PromptVersion = promptVersion, }; - var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest); + var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest, cancellationToken); if (applicationScoringResponse.Answers.Count > 0) { CopyAnswers(applicationScoringResponse.Answers, sectionResults); } } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Error processing AI application scoring section {SectionName} for application {ApplicationId}", section.Name, applicationId); @@ -116,7 +122,8 @@ private async Task>> ProcessSectionsAsync( IReadOnlyCollection sections, JsonElement promptData, List attachmentSummaries, - string? promptVersion) + string? promptVersion, + CancellationToken cancellationToken) { var sectionResults = new Dictionary(); try @@ -134,13 +141,17 @@ private async Task>> ProcessSectionsAsync( SectionSchema = JsonSerializer.SerializeToElement(questions, AIJsonDefaults.IndentedCamelCase), PromptVersion = promptVersion, }; - var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest); + var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest, cancellationToken); if (applicationScoringResponse.Answers.Count > 0) { CopyAnswers(applicationScoringResponse.Answers, sectionResults); } } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Error processing AI application scoring batch for application {ApplicationId}", applicationId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 073b66d86..156ccfda9 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Unity.AI.Extraction; using Unity.AI.Requests; @@ -21,13 +22,13 @@ public class AttachmentSummaryService( { private const string SummaryGenerationFailedMessage = "AI summary generation failed."; - public async Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null) + public async Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null, CancellationToken cancellationToken = default) { var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; - await using var attachmentStream = await OpenAttachmentStreamAsync(attachment, fileName); - var extractedText = await textExtractionService.ExtractTextAsync(fileName, attachmentStream.Content, attachmentStream.ContentType); + await using var attachmentStream = await OpenAttachmentStreamAsync(attachment, fileName, cancellationToken); + var extractedText = await textExtractionService.ExtractTextAsync(fileName, attachmentStream.Content, attachmentStream.ContentType, cancellationToken); var summaryResponse = await aiService.GenerateAttachmentSummaryAsync(new AttachmentSummaryRequest { @@ -35,7 +36,7 @@ public async Task GenerateAndSaveAsync(Guid attachmentId, string? prompt ContentType = attachmentStream.ContentType, ExtractedText = extractedText, PromptVersion = promptVersion, - }); + }, cancellationToken); attachment.AISummary = summaryResponse.Summary; await applicationChefsFileAttachmentRepository.UpdateAsync(attachment); @@ -43,7 +44,7 @@ public async Task GenerateAndSaveAsync(Guid attachmentId, string? prompt return summaryResponse.Summary; } - public async Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null) + public async Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null, CancellationToken cancellationToken = default) { var ids = attachmentIds as IReadOnlyCollection ?? attachmentIds.ToList(); var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); @@ -58,26 +59,36 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen return await AIExecutionStrategy.RunAsync( ids, mode, - id => GenerateOrFallbackAsync(id, promptVersion), - batch => GenerateSequentiallyAsync(batch, promptVersion)); + id => GenerateOrFallbackAsync(id, promptVersion, cancellationToken), + batch => GenerateSequentiallyAsync(batch, promptVersion, cancellationToken)); } - private async Task> GenerateSequentiallyAsync(IReadOnlyCollection attachmentIds, string? promptVersion) + private async Task> GenerateSequentiallyAsync( + IReadOnlyCollection attachmentIds, + string? promptVersion, + CancellationToken cancellationToken) { var summaries = new List(attachmentIds.Count); foreach (var attachmentId in attachmentIds) { - summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion)); + summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion, cancellationToken)); } return summaries; } - private async Task GenerateOrFallbackAsync(Guid attachmentId, string? promptVersion) + private async Task GenerateOrFallbackAsync( + Guid attachmentId, + string? promptVersion, + CancellationToken cancellationToken) { try { - return await GenerateAndSaveAsync(attachmentId, promptVersion); + return await GenerateAndSaveAsync(attachmentId, promptVersion, cancellationToken); + } + catch (OperationCanceledException) + { + throw; } catch (Exception ex) { @@ -86,16 +97,19 @@ private async Task GenerateOrFallbackAsync(Guid attachmentId, string? pr } } - public async Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null) + public async Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { var attachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) .Select(a => a.Id) .ToList(); - return await GenerateAndSaveAsync(attachmentIds, promptVersion); + return await GenerateAndSaveAsync(attachmentIds, promptVersion, cancellationToken); } - private async Task OpenAttachmentStreamAsync(ApplicationChefsFileAttachment attachment, string fileName) + private async Task OpenAttachmentStreamAsync( + ApplicationChefsFileAttachment attachment, + string fileName, + CancellationToken cancellationToken) { if (!Guid.TryParse(attachment.ChefsSubmissionId, out var submissionId) || !Guid.TryParse(attachment.ChefsFileId, out var fileId)) @@ -108,9 +122,14 @@ private async Task OpenAttachmentStreamAsync(Applicat try { + cancellationToken.ThrowIfCancellationRequested(); var stream = await chefsFileAttachmentStreamProvider.OpenAsync(submissionId, fileId, fileName); return stream ?? ChefsFileAttachmentStream.Empty; } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogWarning( diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs index 991a9fe3c..ac0284276 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs @@ -1,10 +1,11 @@ using System; +using System.Threading; using System.Threading.Tasks; namespace Unity.AI.Operations { public interface IApplicationAnalysisService { - Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null); + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs index 96fd5db6d..2b2bc1796 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs @@ -1,10 +1,11 @@ using System; +using System.Threading; using System.Threading.Tasks; namespace Unity.AI.Operations { public interface IApplicationScoringService { - Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null); + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs index c14ea0e01..feed62805 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Unity.AI.Operations; public interface IAttachmentSummaryService { - Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null); - Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null); - Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null); + Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null, CancellationToken cancellationToken = default); + Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null, CancellationToken cancellationToken = default); + Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index f7a54d767..ded93f28c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Unity.AI.Models; using Unity.AI.Prompts; @@ -65,7 +66,7 @@ public Task IsAvailableAsync() return Task.FromResult(true); } - public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) + public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType)); @@ -87,17 +88,19 @@ public async Task GenerateApplicationAnalysisAsync( schema, data, attachments); - await LogPromptInputAsync(ApplicationAnalysisPromptType, promptVersion, systemPrompt, applicationAnalysisContent); + await LogPromptInputAsync(ApplicationAnalysisPromptType, promptVersion, systemPrompt, applicationAnalysisContent, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( applicationAnalysisContent, systemPrompt, ApplicationAnalysisCompletionTokens, operationName: ApplicationAnalysisPromptType, - promptVersion: promptVersion), + promptVersion: promptVersion, + cancellationToken: cancellationToken), AIProviderPayloadValidator.IsValidApplicationAnalysisJson, - "application analysis"); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, result.CaptureOutput); + "application analysis", + cancellationToken); + await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { @@ -107,7 +110,7 @@ public async Task GenerateApplicationAnalysisAsync( return OpenAIResponseParser.ParseApplicationAnalysisResponse(result.Content); } - public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) + public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var fileName = request.FileName ?? string.Empty; @@ -138,7 +141,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta var attachment = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); var contentToAnalyze = OpenAIPromptRenderer.BuildAttachmentSummaryUserPrompt(promptVersion, attachment); - await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze); + await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( contentToAnalyze, @@ -146,10 +149,12 @@ public async Task GenerateAttachmentSummaryAsync(Atta AttachmentSummaryCompletionTokens, operationName: AttachmentSummaryPromptType, promptVersion: promptVersion, - fileName: fileName), + fileName: fileName, + cancellationToken: cancellationToken), AIProviderPayloadValidator.IsValidAttachmentSummaryText, - "attachment summary"); - await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput); + "attachment summary", + cancellationToken); + await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { @@ -164,6 +169,10 @@ public async Task GenerateAttachmentSummaryAsync(Atta Summary = ExtractSummaryFromJson(result.Content) }; } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); @@ -174,7 +183,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta } } - public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request) + public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationScoringPromptType)); @@ -214,17 +223,19 @@ public async Task GenerateApplicationScoringAsync(Ap response); var systemPrompt = OpenAIPromptRenderer.BuildApplicationScoringSystemPrompt(promptVersion); - await LogPromptInputAsync(ApplicationScoringPromptType, promptVersion, systemPrompt, applicationScoringContent); + await LogPromptInputAsync(ApplicationScoringPromptType, promptVersion, systemPrompt, applicationScoringContent, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( applicationScoringContent, systemPrompt, ApplicationScoringCompletionTokens, operationName: ApplicationScoringPromptType, - promptVersion: promptVersion), + promptVersion: promptVersion, + cancellationToken: cancellationToken), content => AIProviderPayloadValidator.IsValidApplicationScoringJson(content, section), - $"application scoring section {request.SectionName}"); - await LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput); + $"application scoring section {request.SectionName}", + cancellationToken); + await LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { @@ -233,6 +244,10 @@ public async Task GenerateApplicationScoringAsync(Ap return OpenAIResponseParser.ParseApplicationScoringResponse(result.Content, questionIdAliasMap); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { _logger.LogError(ex, "Error generating application scoring answers for section {SectionName}", request.SectionName); @@ -243,12 +258,14 @@ public async Task GenerateApplicationScoringAsync(Ap private async Task GenerateWithRetryAsync( Func> operation, Func validator, - string operationName) + string operationName, + CancellationToken cancellationToken = default) { var lastResult = AIOperationResult.InvalidOutput(); for (var attempt = 1; attempt <= MaxAiAttempts; attempt++) { + cancellationToken.ThrowIfCancellationRequested(); lastResult = await operation(); if (lastResult.Outcome == AIOperationOutcome.Success && validator(lastResult.Content)) @@ -320,21 +337,21 @@ private async Task GenerateWithRetryAsync( return _configuration["Azure:OpenAI:PromptVersion"]; } - private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt) + private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt, CancellationToken cancellationToken = default) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, promptVersion, formattedInput); - await WritePromptLogFileAsync(promptType, promptVersion, "INPUT", formattedInput); + await WritePromptLogFileAsync(promptType, promptVersion, "INPUT", formattedInput, cancellationToken); } - private async Task LogPromptOutputAsync(string promptType, string promptVersion, string output) + private async Task LogPromptOutputAsync(string promptType, string promptVersion, string output, CancellationToken cancellationToken = default) { var formattedOutput = FormatPromptOutputForLog(output); _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, promptVersion, formattedOutput); - await WritePromptLogFileAsync(promptType, promptVersion, "OUTPUT", formattedOutput); + await WritePromptLogFileAsync(promptType, promptVersion, "OUTPUT", formattedOutput, cancellationToken); } - private async Task WritePromptLogFileAsync(string promptType, string promptVersion, string payloadType, string payload) + private async Task WritePromptLogFileAsync(string promptType, string promptVersion, string payloadType, string payload, CancellationToken cancellationToken = default) { if (!CanWritePromptFileLog()) { @@ -349,7 +366,11 @@ private async Task WritePromptLogFileAsync(string promptType, string promptVersi var logPath = Path.Combine(logDirectory, PromptLogFileName); var entry = $"{now} [{promptType}] [{promptVersion}] {payloadType}\n{payload}\n\n"; - await File.AppendAllTextAsync(logPath, entry); + await File.AppendAllTextAsync(logPath, entry, cancellationToken); + } + catch (OperationCanceledException) + { + throw; } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs index 374d0755e..f5bc0e219 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Volo.Abp.DependencyInjection; @@ -26,7 +27,8 @@ public async Task GenerateSummaryAsync( double? temperature = null, string? operationName = null, string? promptVersion = null, - string? fileName = null) + string? fileName = null, + CancellationToken cancellationToken = default) { var providerName = _configurationResolver.ResolveProviderName(operationName); if (!string.Equals(providerName, "OpenAI", StringComparison.Ordinal)) @@ -73,8 +75,8 @@ public async Task GenerateSummaryAsync( }; request.Headers.TryAddWithoutValidation("Authorization", apiKey); - var response = await _httpClient.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); var metadata = TryExtractProviderMetadata(responseContent); var providerResponse = BuildProviderResponseFromMetadata( string.Empty, @@ -118,6 +120,10 @@ public async Task GenerateSummaryAsync( return AIOperationResult.InvalidOutput(providerResponse); } } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { _logger.LogError(ex, "Error generating AI summary"); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs index 5d556e0d1..0a5c618e2 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs @@ -4,6 +4,7 @@ using Shouldly; using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using Unity.AI; using Unity.AI.Extraction; @@ -13,16 +14,11 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; using Xunit; -using Xunit.Abstractions; namespace Unity.GrantManager.AI.Operations; -public class AttachmentSummaryServiceTests : GrantManagerApplicationTestBase +public class AttachmentSummaryServiceTests { - public AttachmentSummaryServiceTests(ITestOutputHelper outputHelper) : base(outputHelper) - { - } - [Fact] public async Task GenerateAndSaveAsync_Uses_Streamed_Attachment_Text() { @@ -76,4 +72,41 @@ public async Task GenerateAndSaveAsync_Uses_Streamed_Attachment_Text() await attachmentRepository.Received(1).UpdateAsync(attachment); stream.CanRead.ShouldBeFalse(); } + + [Fact] + public async Task GenerateAndSaveAsync_Should_Propagate_Cancellation() + { + var attachmentId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + var fileId = Guid.NewGuid(); + var stream = new MemoryStream([1, 2, 3]); + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + var attachment = new ApplicationChefsFileAttachment + { + ApplicationId = Guid.NewGuid(), + FileName = "test.txt", + ChefsSubmissionId = submissionId.ToString(), + ChefsFileId = fileId.ToString() + }; + + var attachmentRepository = Substitute.For(); + attachmentRepository.GetAsync(attachmentId).Returns(attachment); + + var streamProvider = Substitute.For(); + streamProvider.OpenAsync(submissionId, fileId, "test.txt") + .Returns(new ChefsFileAttachmentStream(stream, "text/plain")); + + var service = new AttachmentSummaryService( + attachmentRepository, + streamProvider, + Substitute.For(), + Substitute.For(), + new AIExecutionModeResolver(new ConfigurationBuilder().Build()), + NullLogger.Instance); + + await Should.ThrowAsync(() => + service.GenerateAndSaveAsync(attachmentId, "v1", cancellationTokenSource.Token)); + } }