diff --git a/TeachingRecordSystem/Directory.Packages.props b/TeachingRecordSystem/Directory.Packages.props
index f9133c895..8b8d30e2b 100644
--- a/TeachingRecordSystem/Directory.Packages.props
+++ b/TeachingRecordSystem/Directory.Packages.props
@@ -29,6 +29,7 @@
+
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs
index 2a9c369d8..d479ac3fd 100644
--- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs
@@ -16,6 +16,7 @@
using TeachingRecordSystem.AuthorizeAccess.TagHelpers;
using TeachingRecordSystem.Core;
using TeachingRecordSystem.Core.Dqt;
+using TeachingRecordSystem.Core.Services.PersonSearch;
using TeachingRecordSystem.FormFlow;
using TeachingRecordSystem.ServiceDefaults;
using TeachingRecordSystem.SupportUi.Infrastructure.FormFlow;
@@ -101,7 +102,8 @@
})
.AddSingleton()
.AddTransient()
- .AddSingleton, FormTagHelperInitializer>();
+ .AddSingleton, FormTagHelperInitializer>()
+ .AddPersonSearch();
builder.Services.AddOptions()
.Bind(builder.Configuration)
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/NameSynonymsMapping.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/NameSynonymsMapping.cs
new file mode 100644
index 000000000..44bc78db0
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/NameSynonymsMapping.cs
@@ -0,0 +1,17 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using TeachingRecordSystem.Core.DataStore.Postgres.Models;
+
+namespace TeachingRecordSystem.Core.DataStore.Postgres.Mappings;
+
+public class NameSynonymsMapping : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("name_synonyms");
+ builder.HasKey(e => e.NameSynonymsId);
+ builder.Property(e => e.Name).HasMaxLength(NameSynonyms.NameMaxLength).IsRequired().UseCollation("case_insensitive");
+ builder.HasIndex(e => e.Name).IsUnique().HasDatabaseName(NameSynonyms.NameSynonymsIndexName);
+ builder.Property(e => e.Synonyms).IsRequired().UseCollation("case_insensitive");
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/PersonSearchAttributeMapping.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/PersonSearchAttributeMapping.cs
new file mode 100644
index 000000000..0788e7796
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/PersonSearchAttributeMapping.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using TeachingRecordSystem.Core.DataStore.Postgres.Models;
+
+namespace TeachingRecordSystem.Core.DataStore.Postgres.Mappings;
+
+public class PersonSearchAttributeMapping : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("person_search_attributes");
+ builder.HasKey(e => e.PersonSearchAttributeId);
+ builder.Property(e => e.PersonId).IsRequired();
+ builder.HasIndex(e => e.PersonId).HasDatabaseName(PersonSearchAttribute.PersonIdIndexName);
+ builder.Property(e => e.AttributeType).HasMaxLength(PersonSearchAttribute.AttributeTypeMaxLength).IsRequired().UseCollation("case_insensitive");
+ builder.Property(e => e.AttributeValue).HasMaxLength(PersonSearchAttribute.AttributeValueMaxLength).IsRequired().UseCollation("case_insensitive");
+ builder.Property(e => e.AttributeKey).HasMaxLength(PersonSearchAttribute.AttributeKeyMaxLength).UseCollation("case_insensitive");
+ builder.HasIndex(e => new { e.AttributeType, e.AttributeValue }).HasDatabaseName(PersonSearchAttribute.AttributeTypeAndValueIndexName);
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240201153254_PersonSearchAttributeAndNameSynonyms.Designer.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240201153254_PersonSearchAttributeAndNameSynonyms.Designer.cs
new file mode 100644
index 000000000..77fb56389
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240201153254_PersonSearchAttributeAndNameSynonyms.Designer.cs
@@ -0,0 +1,1169 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeachingRecordSystem.Core.DataStore.Postgres;
+
+#nullable disable
+
+namespace TeachingRecordSystem.Core.DataStore.Postgres.Migrations
+{
+ [DbContext(typeof(TrsDbContext))]
+ [Migration("20240201153254_PersonSearchAttributeAndNameSynonyms")]
+ partial class PersonSearchAttributeAndNameSynonyms
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApiKey", b =>
+ {
+ b.Property("ApiKeyId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("api_key_id");
+
+ b.Property("ApplicationUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("application_user_id");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_on");
+
+ b.Property("Expires")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("key");
+
+ b.Property("UpdatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_on");
+
+ b.HasKey("ApiKeyId")
+ .HasName("pk_api_keys");
+
+ b.HasIndex("ApplicationUserId")
+ .HasDatabaseName("ix_api_keys_application_user_id");
+
+ b.HasIndex("Key")
+ .IsUnique()
+ .HasDatabaseName("ix_api_keys_key");
+
+ b.ToTable("api_keys", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EntityChangesJournal", b =>
+ {
+ b.Property("Key")
+ .HasColumnType("text")
+ .HasColumnName("key");
+
+ b.Property("EntityLogicalName")
+ .HasColumnType("text")
+ .HasColumnName("entity_logical_name");
+
+ b.Property("DataToken")
+ .HasColumnType("text")
+ .HasColumnName("data_token");
+
+ b.Property("LastUpdated")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_updated");
+
+ b.Property("LastUpdatedBy")
+ .HasColumnType("text")
+ .HasColumnName("last_updated_by");
+
+ b.Property("NextQueryPageNumber")
+ .HasColumnType("integer")
+ .HasColumnName("next_query_page_number");
+
+ b.Property("NextQueryPageSize")
+ .HasColumnType("integer")
+ .HasColumnName("next_query_page_size");
+
+ b.Property("NextQueryPagingCookie")
+ .HasColumnType("text")
+ .HasColumnName("next_query_paging_cookie");
+
+ b.HasKey("Key", "EntityLogicalName")
+ .HasName("pk_entity_changes_journals");
+
+ b.ToTable("entity_changes_journals", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Event", b =>
+ {
+ b.Property("EventId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("event_id");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property("EventName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("event_name");
+
+ b.Property("Inserted")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("inserted");
+
+ b.Property("Key")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("key");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("payload");
+
+ b.Property("Published")
+ .HasColumnType("boolean")
+ .HasColumnName("published");
+
+ b.HasKey("EventId")
+ .HasName("pk_events");
+
+ b.HasIndex("Key")
+ .IsUnique()
+ .HasDatabaseName("ix_events_key")
+ .HasFilter("key is not null");
+
+ b.HasIndex("Payload")
+ .HasDatabaseName("ix_events_payload");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Payload"), "gin");
+
+ b.ToTable("events", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", b =>
+ {
+ b.Property("EytsAwardedEmailsJobId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("eyts_awarded_emails_job_id");
+
+ b.Property("AwardedToUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("awarded_to_utc");
+
+ b.Property("ExecutedUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("executed_utc");
+
+ b.HasKey("EytsAwardedEmailsJobId")
+ .HasName("pk_eyts_awarded_emails_jobs");
+
+ b.HasIndex("ExecutedUtc")
+ .HasDatabaseName("ix_eyts_awarded_emails_jobs_executed_utc");
+
+ b.ToTable("eyts_awarded_emails_jobs", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJobItem", b =>
+ {
+ b.Property("EytsAwardedEmailsJobId")
+ .HasColumnType("uuid")
+ .HasColumnName("eyts_awarded_emails_job_id");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("EmailAddress")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email_address");
+
+ b.Property("EmailSent")
+ .HasColumnType("boolean")
+ .HasColumnName("email_sent");
+
+ b.Property("Personalization")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("personalization");
+
+ b.Property("Trn")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("character(7)")
+ .HasColumnName("trn")
+ .IsFixedLength();
+
+ b.HasKey("EytsAwardedEmailsJobId", "PersonId")
+ .HasName("pk_eyts_awarded_emails_job_items");
+
+ b.HasIndex("Personalization")
+ .HasDatabaseName("ix_eyts_awarded_emails_job_items_personalization");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin");
+
+ b.ToTable("eyts_awarded_emails_job_items", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", b =>
+ {
+ b.Property("InductionCompletedEmailsJobId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("induction_completed_emails_job_id");
+
+ b.Property("AwardedToUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("awarded_to_utc");
+
+ b.Property("ExecutedUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("executed_utc");
+
+ b.HasKey("InductionCompletedEmailsJobId")
+ .HasName("pk_induction_completed_emails_jobs");
+
+ b.HasIndex("ExecutedUtc")
+ .HasDatabaseName("ix_induction_completed_emails_jobs_executed_utc");
+
+ b.ToTable("induction_completed_emails_jobs", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJobItem", b =>
+ {
+ b.Property("InductionCompletedEmailsJobId")
+ .HasColumnType("uuid")
+ .HasColumnName("induction_completed_emails_job_id");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("EmailAddress")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email_address");
+
+ b.Property("EmailSent")
+ .HasColumnType("boolean")
+ .HasColumnName("email_sent");
+
+ b.Property("Personalization")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("personalization");
+
+ b.Property("Trn")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("character(7)")
+ .HasColumnName("trn")
+ .IsFixedLength();
+
+ b.HasKey("InductionCompletedEmailsJobId", "PersonId")
+ .HasName("pk_induction_completed_emails_job_items");
+
+ b.HasIndex("Personalization")
+ .HasDatabaseName("ix_induction_completed_emails_job_items_personalization");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin");
+
+ b.ToTable("induction_completed_emails_job_items", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", b =>
+ {
+ b.Property("InternationalQtsAwardedEmailsJobId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("international_qts_awarded_emails_job_id");
+
+ b.Property("AwardedToUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("awarded_to_utc");
+
+ b.Property("ExecutedUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("executed_utc");
+
+ b.HasKey("InternationalQtsAwardedEmailsJobId")
+ .HasName("pk_international_qts_awarded_emails_jobs");
+
+ b.HasIndex("ExecutedUtc")
+ .HasDatabaseName("ix_international_qts_awarded_emails_jobs_executed_utc");
+
+ b.ToTable("international_qts_awarded_emails_jobs", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJobItem", b =>
+ {
+ b.Property("InternationalQtsAwardedEmailsJobId")
+ .HasColumnType("uuid")
+ .HasColumnName("international_qts_awarded_emails_job_id");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("EmailAddress")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email_address");
+
+ b.Property("EmailSent")
+ .HasColumnType("boolean")
+ .HasColumnName("email_sent");
+
+ b.Property("Personalization")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("personalization");
+
+ b.Property("Trn")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("character(7)")
+ .HasColumnName("trn")
+ .IsFixedLength();
+
+ b.HasKey("InternationalQtsAwardedEmailsJobId", "PersonId")
+ .HasName("pk_international_qts_awarded_emails_job_items");
+
+ b.HasIndex("Personalization")
+ .HasDatabaseName("ix_international_qts_awarded_emails_job_items_personalization");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin");
+
+ b.ToTable("international_qts_awarded_emails_job_items", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.JourneyState", b =>
+ {
+ b.Property("InstanceId")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("instance_id");
+
+ b.Property("Completed")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("completed");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("state");
+
+ b.Property("Updated")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("user_id");
+
+ b.HasKey("InstanceId")
+ .HasName("pk_journey_states");
+
+ b.ToTable("journey_states", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualificationProvider", b =>
+ {
+ b.Property("MandatoryQualificationProviderId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("mandatory_qualification_provider_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.HasKey("MandatoryQualificationProviderId")
+ .HasName("pk_mandatory_qualification_providers");
+
+ b.ToTable("mandatory_qualification_providers", (string)null);
+
+ b.HasData(
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("e28ea41d-408d-4c89-90cc-8b9b04ac68f5"),
+ Name = "University of Birmingham"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("89f9a1aa-3d68-4985-a4ce-403b6044c18c"),
+ Name = "University of Leeds"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("aa5c300e-3b7c-456c-8183-3520b3d55dca"),
+ Name = "University of Manchester"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("f417e73e-e2ad-40eb-85e3-55865be7f6be"),
+ Name = "Mary Hare School / University of Hertfordshire"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("fbf22e04-b274-4c80-aba8-79fb6a7a32ce"),
+ Name = "University of Edinburgh"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("26204149-349c-4ad6-9466-bb9b83723eae"),
+ Name = "Liverpool John Moores University"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("0c30f666-647c-4ea8-8883-0fc6010b56be"),
+ Name = "University of Oxford/Oxford Polytechnic"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("d0e6d54c-5e90-438a-945d-f97388c2b352"),
+ Name = "University of Cambridge"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("aec32252-ef25-452e-a358-34a04e03369c"),
+ Name = "University of Newcastle-upon-Tyne"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("d9ee7054-7fde-4cfd-9a5e-4b99511d1b3d"),
+ Name = "University of Plymouth"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("707d58ca-1953-413b-9a46-41e9b0be885e"),
+ Name = "University of Hertfordshire"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("3fc648a7-18e4-49e7-8a4b-1612616b72d5"),
+ Name = "University of London"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("374dceb8-8224-45b8-b7dc-a6b0282b1065"),
+ Name = "Bristol Polytechnic"
+ },
+ new
+ {
+ MandatoryQualificationProviderId = new Guid("d4fc958b-21de-47ec-9f03-36ae237a1b11"),
+ Name = "University College, Swansea"
+ });
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.NameSynonyms", b =>
+ {
+ b.Property("NameSynonymsId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("name_synonyms_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NameSynonymsId"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name")
+ .UseCollation("case_insensitive");
+
+ b.Property("Synonyms")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("synonyms")
+ .UseCollation("case_insensitive");
+
+ b.HasKey("NameSynonymsId")
+ .HasName("pk_name_synonyms");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_name_synonyms_name");
+
+ b.ToTable("name_synonyms", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b =>
+ {
+ b.Property("Subject")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("subject");
+
+ b.Property("CoreIdentityVc")
+ .HasColumnType("jsonb")
+ .HasColumnName("core_identity_vc");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email");
+
+ b.Property("FirstOneLoginSignIn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_one_login_sign_in");
+
+ b.Property("FirstSignIn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_sign_in");
+
+ b.Property("LastOneLoginSignIn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_one_login_sign_in");
+
+ b.Property("LastSignIn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_sign_in");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.HasKey("Subject")
+ .HasName("pk_one_login_users");
+
+ b.ToTable("one_login_users", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", b =>
+ {
+ b.Property("PersonId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_on");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date")
+ .HasColumnName("date_of_birth");
+
+ b.Property("DeletedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_on");
+
+ b.Property("DqtContactId")
+ .HasColumnType("uuid")
+ .HasColumnName("dqt_contact_id");
+
+ b.Property("DqtCreatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_created_on");
+
+ b.Property("DqtFirstName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("dqt_first_name")
+ .UseCollation("case_insensitive");
+
+ b.Property("DqtFirstSync")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_first_sync");
+
+ b.Property("DqtLastName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("dqt_last_name")
+ .UseCollation("case_insensitive");
+
+ b.Property("DqtLastSync")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_last_sync");
+
+ b.Property("DqtMiddleName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("dqt_middle_name")
+ .UseCollation("case_insensitive");
+
+ b.Property("DqtModifiedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_modified_on");
+
+ b.Property("DqtState")
+ .HasColumnType("integer")
+ .HasColumnName("dqt_state");
+
+ b.Property("EmailAddress")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("email_address")
+ .UseCollation("case_insensitive");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("first_name")
+ .UseCollation("case_insensitive");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("last_name")
+ .UseCollation("case_insensitive");
+
+ b.Property("MiddleName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("middle_name")
+ .UseCollation("case_insensitive");
+
+ b.Property("NationalInsuranceNumber")
+ .HasMaxLength(9)
+ .HasColumnType("character(9)")
+ .HasColumnName("national_insurance_number")
+ .IsFixedLength();
+
+ b.Property("Trn")
+ .HasMaxLength(7)
+ .HasColumnType("character(7)")
+ .HasColumnName("trn")
+ .IsFixedLength();
+
+ b.Property("UpdatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_on");
+
+ b.HasKey("PersonId")
+ .HasName("pk_persons");
+
+ b.HasIndex("DqtContactId")
+ .IsUnique()
+ .HasDatabaseName("ix_persons_dqt_contact_id")
+ .HasFilter("dqt_contact_id is not null");
+
+ b.HasIndex("Trn")
+ .IsUnique()
+ .HasDatabaseName("ix_persons_trn")
+ .HasFilter("trn is not null");
+
+ b.ToTable("persons", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.PersonSearchAttribute", b =>
+ {
+ b.Property("PersonSearchAttributeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("person_search_attribute_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersonSearchAttributeId"));
+
+ b.Property("AttributeKey")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("attribute_key")
+ .UseCollation("case_insensitive");
+
+ b.Property("AttributeType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("attribute_type")
+ .UseCollation("case_insensitive");
+
+ b.Property("AttributeValue")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("attribute_value")
+ .UseCollation("case_insensitive");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("Tags")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("tags");
+
+ b.HasKey("PersonSearchAttributeId")
+ .HasName("pk_person_search_attributes");
+
+ b.HasIndex("PersonId")
+ .HasDatabaseName("ix_person_search_attributes_person_id");
+
+ b.HasIndex("AttributeType", "AttributeValue")
+ .HasDatabaseName("ix_person_search_attributes_attribute_type_and_value");
+
+ b.ToTable("person_search_attributes", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", b =>
+ {
+ b.Property("QtsAwardedEmailsJobId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("qts_awarded_emails_job_id");
+
+ b.Property("AwardedToUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("awarded_to_utc");
+
+ b.Property("ExecutedUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("executed_utc");
+
+ b.HasKey("QtsAwardedEmailsJobId")
+ .HasName("pk_qts_awarded_emails_jobs");
+
+ b.HasIndex("ExecutedUtc")
+ .HasDatabaseName("ix_qts_awarded_emails_jobs_executed_utc");
+
+ b.ToTable("qts_awarded_emails_jobs", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJobItem", b =>
+ {
+ b.Property("QtsAwardedEmailsJobId")
+ .HasColumnType("uuid")
+ .HasColumnName("qts_awarded_emails_job_id");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("EmailAddress")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email_address");
+
+ b.Property("EmailSent")
+ .HasColumnType("boolean")
+ .HasColumnName("email_sent");
+
+ b.Property("Personalization")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("personalization");
+
+ b.Property("Trn")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("character(7)")
+ .HasColumnName("trn")
+ .IsFixedLength();
+
+ b.HasKey("QtsAwardedEmailsJobId", "PersonId")
+ .HasName("pk_qts_awarded_emails_job_items");
+
+ b.HasIndex("Personalization")
+ .HasDatabaseName("ix_qts_awarded_emails_job_items_personalization");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin");
+
+ b.ToTable("qts_awarded_emails_job_items", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification", b =>
+ {
+ b.Property("QualificationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("qualification_id");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_on");
+
+ b.Property("DeletedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_on");
+
+ b.Property("DqtCreatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_created_on");
+
+ b.Property("DqtFirstSync")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_first_sync");
+
+ b.Property("DqtLastSync")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_last_sync");
+
+ b.Property("DqtModifiedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dqt_modified_on");
+
+ b.Property("DqtQualificationId")
+ .HasColumnType("uuid")
+ .HasColumnName("dqt_qualification_id");
+
+ b.Property("DqtState")
+ .HasColumnType("integer")
+ .HasColumnName("dqt_state");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("QualificationType")
+ .HasColumnType("integer")
+ .HasColumnName("qualification_type");
+
+ b.Property("UpdatedOn")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_on");
+
+ b.HasKey("QualificationId")
+ .HasName("pk_qualifications");
+
+ b.HasIndex("DqtQualificationId")
+ .IsUnique()
+ .HasDatabaseName("ix_qualifications_dqt_qualification_id")
+ .HasFilter("dqt_qualification_id is not null");
+
+ b.HasIndex("PersonId")
+ .HasDatabaseName("ix_qualifications_person_id");
+
+ b.ToTable("qualifications", (string)null);
+
+ b.HasDiscriminator("QualificationType");
+
+ b.UseTphMappingStrategy();
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TrnRequest", b =>
+ {
+ b.Property("TrnRequestId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("trn_request_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TrnRequestId"));
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("client_id");
+
+ b.Property("IdentityUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("identity_user_id");
+
+ b.Property("LinkedToIdentity")
+ .HasColumnType("boolean")
+ .HasColumnName("linked_to_identity");
+
+ b.Property("RequestId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("request_id");
+
+ b.Property("TeacherId")
+ .HasColumnType("uuid")
+ .HasColumnName("teacher_id");
+
+ b.Property("TrnToken")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("trn_token");
+
+ b.HasKey("TrnRequestId")
+ .HasName("pk_trn_requests");
+
+ b.HasIndex("ClientId", "RequestId")
+ .IsUnique()
+ .HasDatabaseName("ix_trn_requests_client_id_request_id");
+
+ b.ToTable("trn_requests", (string)null);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase", b =>
+ {
+ b.Property("UserId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Active")
+ .HasColumnType("boolean")
+ .HasColumnName("active");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("UserType")
+ .HasColumnType("integer")
+ .HasColumnName("user_type");
+
+ b.HasKey("UserId")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+
+ b.HasDiscriminator("UserType");
+
+ b.UseTphMappingStrategy();
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualification", b =>
+ {
+ b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification");
+
+ b.Property("DqtMqEstablishmentId")
+ .HasColumnType("uuid")
+ .HasColumnName("dqt_mq_establishment_id");
+
+ b.Property("DqtSpecialismId")
+ .HasColumnType("uuid")
+ .HasColumnName("dqt_specialism_id");
+
+ b.Property("EndDate")
+ .HasColumnType("date")
+ .HasColumnName("end_date");
+
+ b.Property("ProviderId")
+ .HasColumnType("uuid")
+ .HasColumnName("mq_provider_id");
+
+ b.Property("Specialism")
+ .HasColumnType("integer")
+ .HasColumnName("mq_specialism");
+
+ b.Property("StartDate")
+ .HasColumnType("date")
+ .HasColumnName("start_date");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("mq_status");
+
+ b.HasDiscriminator().HasValue(0);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", b =>
+ {
+ b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase");
+
+ b.Property("ApiRoles")
+ .IsRequired()
+ .HasColumnType("varchar[]")
+ .HasColumnName("api_roles");
+
+ b.Property("OneLoginClientId")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("one_login_client_id");
+
+ b.Property("OneLoginPrivateKeyPem")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)")
+ .HasColumnName("one_login_private_key_pem");
+
+ b.HasDiscriminator().HasValue(2);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.SystemUser", b =>
+ {
+ b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase");
+
+ b.HasDiscriminator().HasValue(3);
+
+ b.HasData(
+ new
+ {
+ UserId = new Guid("a81394d1-a498-46d8-af3e-e077596ab303"),
+ Active = true,
+ Name = "System",
+ UserType = 0
+ });
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.User", b =>
+ {
+ b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase");
+
+ b.Property("AzureAdUserId")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("azure_ad_user_id");
+
+ b.Property("DqtUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("dqt_user_id");
+
+ b.Property("Email")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email")
+ .UseCollation("case_insensitive");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("varchar[]")
+ .HasColumnName("roles");
+
+ b.HasIndex("AzureAdUserId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_azure_ad_user_id");
+
+ b.HasDiscriminator().HasValue(1);
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApiKey", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", "ApplicationUser")
+ .WithMany("ApiKeys")
+ .HasForeignKey("ApplicationUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_api_key_application_user");
+
+ b.Navigation("ApplicationUser");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJobItem", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", "EytsAwardedEmailsJob")
+ .WithMany("JobItems")
+ .HasForeignKey("EytsAwardedEmailsJobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_eyts_awarded_emails_job_items_eyts_awarded_emails_jobs_eyts");
+
+ b.Navigation("EytsAwardedEmailsJob");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJobItem", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", "InductionCompletedEmailsJob")
+ .WithMany("JobItems")
+ .HasForeignKey("InductionCompletedEmailsJobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_induction_completed_emails_job_items_induction_completed_em");
+
+ b.Navigation("InductionCompletedEmailsJob");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJobItem", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", "InternationalQtsAwardedEmailsJob")
+ .WithMany("JobItems")
+ .HasForeignKey("InternationalQtsAwardedEmailsJobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_international_qts_awarded_emails_job_items_international_qt");
+
+ b.Navigation("InternationalQtsAwardedEmailsJob");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", "Person")
+ .WithOne()
+ .HasForeignKey("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", "PersonId")
+ .HasConstraintName("fk_one_login_users_persons_person_id");
+
+ b.Navigation("Person");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJobItem", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", "QtsAwardedEmailsJob")
+ .WithMany("JobItems")
+ .HasForeignKey("QtsAwardedEmailsJobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_qts_awarded_emails_job_items_qts_awarded_emails_jobs_qts_aw");
+
+ b.Navigation("QtsAwardedEmailsJob");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", null)
+ .WithMany()
+ .HasForeignKey("PersonId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_qualifications_person");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualification", b =>
+ {
+ b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualificationProvider", null)
+ .WithMany()
+ .HasForeignKey("ProviderId")
+ .HasConstraintName("fk_qualifications_mandatory_qualification_provider");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", b =>
+ {
+ b.Navigation("JobItems");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", b =>
+ {
+ b.Navigation("JobItems");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", b =>
+ {
+ b.Navigation("JobItems");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", b =>
+ {
+ b.Navigation("JobItems");
+ });
+
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", b =>
+ {
+ b.Navigation("ApiKeys");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240201153254_PersonSearchAttributeAndNameSynonyms.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240201153254_PersonSearchAttributeAndNameSynonyms.cs
new file mode 100644
index 000000000..93c248c46
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240201153254_PersonSearchAttributeAndNameSynonyms.cs
@@ -0,0 +1,267 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace TeachingRecordSystem.Core.DataStore.Postgres.Migrations
+{
+ ///
+ public partial class PersonSearchAttributeAndNameSynonyms : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "name_synonyms",
+ columns: table => new
+ {
+ name_synonyms_id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false, collation: "case_insensitive"),
+ synonyms = table.Column(type: "text[]", nullable: false, collation: "case_insensitive")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_name_synonyms", x => x.name_synonyms_id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "person_search_attributes",
+ columns: table => new
+ {
+ person_search_attribute_id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ person_id = table.Column(type: "uuid", nullable: false),
+ attribute_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false, collation: "case_insensitive"),
+ attribute_value = table.Column(type: "character varying(100)", maxLength: 100, nullable: false, collation: "case_insensitive"),
+ tags = table.Column(type: "text[]", nullable: false),
+ attribute_key = table.Column(type: "character varying(50)", maxLength: 50, nullable: true, collation: "case_insensitive")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_person_search_attributes", x => x.person_search_attribute_id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_name_synonyms_name",
+ table: "name_synonyms",
+ column: "name",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_person_search_attributes_attribute_type_and_value",
+ table: "person_search_attributes",
+ columns: new[] { "attribute_type", "attribute_value" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_person_search_attributes_person_id",
+ table: "person_search_attributes",
+ column: "person_id");
+
+ var refreshNameProcedureSql = @"
+CREATE OR REPLACE PROCEDURE public.p_refresh_name_person_search_attributes(
+ IN p_person_id uuid,
+ IN p_first_name character varying,
+ IN p_last_name character varying,
+ IN p_attribute_key character varying)
+LANGUAGE 'plpgsql'
+AS $BODY$
+BEGIN
+ DELETE FROM
+ person_search_attributes
+ WHERE
+ person_id = p_person_id
+ AND attribute_key = p_attribute_key;
+
+ INSERT INTO
+ person_search_attributes
+ (
+ person_id,
+ attribute_type,
+ attribute_value,
+ tags,
+ attribute_key
+ )
+ SELECT
+ p_person_id,
+ attribs.attribute_type,
+ attribs.attribute_value,
+ ARRAY[]::text[],
+ p_attribute_key
+ FROM
+ (VALUES
+ ('FirstName', p_first_name),
+ ('LastName', p_last_name)) AS attribs (attribute_type, attribute_value)
+ WHERE
+ attribs.attribute_value IS NOT NULL;
+
+ -- Insert synonyms of first name
+ INSERT INTO
+ person_search_attributes
+ (
+ person_id,
+ attribute_type,
+ attribute_value,
+ tags,
+ attribute_key
+ )
+ SELECT
+ p_person_id,
+ 'FirstName',
+ UNNEST(synonyms),
+ ARRAY[CONCAT('Synonym:', p_first_name)],
+ p_attribute_key
+ FROM
+ name_synonyms
+ WHERE
+ name = p_first_name;
+
+ -- Insert full name as a searchable attribute
+ INSERT INTO
+ person_search_attributes
+ (
+ person_id,
+ attribute_type,
+ attribute_value,
+ tags,
+ attribute_key
+ )
+ SELECT
+ first_names.person_id,
+ 'FullName',
+ first_names.attribute_value || ' ' || last_names.attribute_value,
+ '{}',
+ first_names.attribute_key
+ FROM
+ person_search_attributes first_names
+ JOIN
+ person_search_attributes last_names ON first_names.person_id = last_names.person_id AND first_names.attribute_key = last_names.attribute_key
+ WHERE
+ first_names.person_id = p_person_id
+ AND first_names.attribute_type = 'FirstName'
+ AND last_names.attribute_type = 'LastName';
+END;
+$BODY$;
+";
+ migrationBuilder.Sql(refreshNameProcedureSql);
+
+ var refreshProcedureSql = @"
+CREATE OR REPLACE PROCEDURE public.p_refresh_person_search_attributes(
+ IN p_person_id uuid,
+ IN p_first_name character varying(100),
+ IN p_last_name character varying(100),
+ IN p_date_of_birth date,
+ IN p_national_insurance_number character(9),
+ IN p_trn character(7))
+LANGUAGE 'plpgsql'
+AS $BODY$
+BEGIN
+ DELETE FROM
+ person_search_attributes
+ WHERE
+ person_id = p_person_id
+ AND attribute_key IS NULL;
+
+ INSERT INTO
+ person_search_attributes
+ (
+ person_id,
+ attribute_type,
+ attribute_value,
+ tags
+ )
+ SELECT
+ p_person_id,
+ attribs.attribute_type,
+ attribs.attribute_value,
+ '{}'
+ FROM
+ (VALUES
+ ('DateOfBirth', CASE WHEN p_date_of_birth IS NULL THEN NULL ELSE to_char(p_date_of_birth, 'yyyy-mm-dd') END),
+ ('NationalInsuranceNumber', p_national_insurance_number),
+ ('Trn', p_trn)) AS attribs (attribute_type, attribute_value)
+ WHERE
+ attribs.attribute_value IS NOT NULL;
+
+ CALL p_refresh_name_person_search_attributes(
+ p_person_id,
+ p_first_name,
+ p_last_name,
+ '1');
+END;
+$BODY$;
+";
+ migrationBuilder.Sql(refreshProcedureSql);
+
+ var triggerFunctionSql = @"
+CREATE OR REPLACE FUNCTION fn_update_person_search_attributes()
+ RETURNS trigger
+ LANGUAGE 'plpgsql'
+AS $BODY$
+BEGIN
+ IF ((TG_OP = 'DELETE')) THEN
+ DELETE FROM
+ person_search_attributes
+ WHERE
+ person_id = OLD.person_id;
+ END IF;
+
+ IF (((TG_OP = 'INSERT') OR (TG_OP = 'UPDATE')) AND NEW.deleted_on IS NULL) THEN
+ CALL p_refresh_person_search_attributes(
+ NEW.person_id,
+ NEW.first_name,
+ NEW.last_name,
+ NEW.date_of_birth,
+ NEW.national_insurance_number,
+ NEW.trn);
+ END IF;
+
+ RETURN NULL; -- result is ignored since this is an AFTER trigger
+END;
+$BODY$
+";
+ migrationBuilder.Sql(triggerFunctionSql);
+
+ var triggerSql = @"
+CREATE TRIGGER trg_update_person_search_attributes
+ AFTER INSERT OR DELETE OR UPDATE OF first_name, last_name, date_of_birth, national_insurance_number, trn, deleted_on
+ ON persons
+ FOR EACH ROW
+ EXECUTE FUNCTION fn_update_person_search_attributes()
+";
+ migrationBuilder.Sql(triggerSql);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ var dropTriggerSql = @"
+DROP TRIGGER trg_update_person_search_attributes ON persons
+";
+ migrationBuilder.Sql(dropTriggerSql);
+
+ var dropTriggerFunctionSql = @"
+DROP FUNCTION fn_update_person_search_attributes()
+";
+ migrationBuilder.Sql(dropTriggerFunctionSql);
+
+ var dropRefreshProcedureSql = @"
+DROP PROCEDURE public.p_refresh_person_search_attributes(uuid, character varying, character varying, date, character, character)
+";
+ migrationBuilder.Sql(dropRefreshProcedureSql);
+
+ var dropRefreshNameProcedureSql = @"
+DROP PROCEDURE public.p_refresh_name_person_search_attributes(uuid, character varying, character varying, character varying)
+";
+
+ migrationBuilder.Sql(dropRefreshNameProcedureSql);
+
+ migrationBuilder.DropTable(
+ name: "name_synonyms");
+
+ migrationBuilder.DropTable(
+ name: "person_search_attributes");
+ }
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs
index c699d5e56..e66a4ed85 100644
--- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs
@@ -484,6 +484,38 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
});
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.NameSynonyms", b =>
+ {
+ b.Property("NameSynonymsId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("name_synonyms_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NameSynonymsId"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name")
+ .UseCollation("case_insensitive");
+
+ b.Property("Synonyms")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("synonyms")
+ .UseCollation("case_insensitive");
+
+ b.HasKey("NameSynonymsId")
+ .HasName("pk_name_synonyms");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_name_synonyms_name");
+
+ b.ToTable("name_synonyms", (string)null);
+ });
+
modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b =>
{
b.Property("Subject")
@@ -647,6 +679,56 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("persons", (string)null);
});
+ modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.PersonSearchAttribute", b =>
+ {
+ b.Property("PersonSearchAttributeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("person_search_attribute_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersonSearchAttributeId"));
+
+ b.Property("AttributeKey")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("attribute_key")
+ .UseCollation("case_insensitive");
+
+ b.Property("AttributeType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("attribute_type")
+ .UseCollation("case_insensitive");
+
+ b.Property("AttributeValue")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("attribute_value")
+ .UseCollation("case_insensitive");
+
+ b.Property("PersonId")
+ .HasColumnType("uuid")
+ .HasColumnName("person_id");
+
+ b.Property("Tags")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("tags");
+
+ b.HasKey("PersonSearchAttributeId")
+ .HasName("pk_person_search_attributes");
+
+ b.HasIndex("PersonId")
+ .HasDatabaseName("ix_person_search_attributes_person_id");
+
+ b.HasIndex("AttributeType", "AttributeValue")
+ .HasDatabaseName("ix_person_search_attributes_attribute_type_and_value");
+
+ b.ToTable("person_search_attributes", (string)null);
+ });
+
modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", b =>
{
b.Property("QtsAwardedEmailsJobId")
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/NameSynonyms.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/NameSynonyms.cs
new file mode 100644
index 000000000..18589dc1c
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/NameSynonyms.cs
@@ -0,0 +1,13 @@
+namespace TeachingRecordSystem.Core.DataStore.Postgres.Models;
+
+public class NameSynonyms
+{
+ public const int NameMaxLength = 100;
+ public const string NameSynonymsIndexName = "ix_name_synonyms_name";
+
+ public long NameSynonymsId { get; init; }
+
+ public required string Name { get; init; }
+
+ public required string[] Synonyms { get; set; }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/PersonSearchAttribute.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/PersonSearchAttribute.cs
new file mode 100644
index 000000000..40db76d93
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/PersonSearchAttribute.cs
@@ -0,0 +1,22 @@
+namespace TeachingRecordSystem.Core.DataStore.Postgres.Models;
+
+public class PersonSearchAttribute
+{
+ public const int AttributeTypeMaxLength = 50;
+ public const int AttributeValueMaxLength = 100;
+ public const int AttributeKeyMaxLength = 50;
+ public const string PersonIdIndexName = "ix_person_search_attributes_person_id";
+ public const string AttributeTypeAndValueIndexName = "ix_person_search_attributes_attribute_type_and_value";
+
+ public long PersonSearchAttributeId { get; init; }
+
+ public required Guid PersonId { get; init; }
+
+ public required string AttributeType { get; init; }
+
+ public required string AttributeValue { get; init; }
+
+ public required string[] Tags { get; init; }
+
+ public string? AttributeKey { get; init; }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs
index e29a9d764..84b490a9c 100644
--- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs
@@ -55,6 +55,10 @@ public static TrsDbContext Create(string connectionString, int? commandTimeout =
public DbSet OneLoginUsers => Set();
+ public DbSet NameSynonyms => Set();
+
+ public DbSet PersonSearchAttributes => Set();
+
public static void ConfigureOptions(DbContextOptionsBuilder optionsBuilder, string connectionString, int? commandTimeout = null)
{
Action configureOptions = o => o.CommandTimeout(commandTimeout);
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/HostApplicationBuilderExtensions.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/HostApplicationBuilderExtensions.cs
index abeaac137..0584658b9 100644
--- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/HostApplicationBuilderExtensions.cs
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/HostApplicationBuilderExtensions.cs
@@ -44,6 +44,7 @@ public static IHostApplicationBuilder AddBackgroundJobs(this IHostApplicationBui
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
+ builder.Services.AddHttpClient();
builder.Services.AddStartupTask(sp =>
{
@@ -90,6 +91,16 @@ public static IHostApplicationBuilder AddBackgroundJobs(this IHostApplicationBui
job => job.Execute(CancellationToken.None),
Cron.Never);
+ recurringJobManager.AddOrUpdate(
+ nameof(PopulateNameSynonymsJob),
+ job => job.Execute(CancellationToken.None),
+ Cron.Never);
+
+ recurringJobManager.AddOrUpdate(
+ nameof(PopulateAllPersonsSearchAttributesJob),
+ job => job.Execute(),
+ Cron.Never);
+
return Task.CompletedTask;
});
}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulateAllPersonsSearchAttributesJob.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulateAllPersonsSearchAttributesJob.cs
new file mode 100644
index 000000000..fe716167f
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulateAllPersonsSearchAttributesJob.cs
@@ -0,0 +1,18 @@
+using Hangfire;
+using Microsoft.EntityFrameworkCore;
+using TeachingRecordSystem.Core.DataStore.Postgres;
+using TeachingRecordSystem.Core.Jobs.Scheduling;
+
+namespace TeachingRecordSystem.Core.Jobs;
+
+[AutomaticRetry(Attempts = 0)]
+public class PopulateAllPersonsSearchAttributesJob(TrsDbContext dbContext, IBackgroundJobScheduler backgroundJobScheduler)
+{
+ public async Task Execute()
+ {
+ await foreach (var personId in dbContext.Persons.AsNoTracking().Select(p => p.PersonId).AsAsyncEnumerable())
+ {
+ await backgroundJobScheduler.Enqueue(j => j.Execute(personId));
+ }
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulateNameSynonymsJob.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulateNameSynonymsJob.cs
new file mode 100644
index 000000000..154b04318
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulateNameSynonymsJob.cs
@@ -0,0 +1,68 @@
+using Microsoft.EntityFrameworkCore;
+using TeachingRecordSystem.Core.DataStore.Postgres;
+using TeachingRecordSystem.Core.DataStore.Postgres.Models;
+
+namespace TeachingRecordSystem.Core.Jobs;
+
+public class PopulateNameSynonymsJob(TrsDbContext dbContext, HttpClient httpClient)
+{
+ public async Task Execute(CancellationToken cancellationToken)
+ {
+ using var stream = await httpClient.GetStreamAsync("https://raw.githubusercontent.com/carltonnorthern/nicknames/master/names.csv");
+ using var reader = new StreamReader(stream);
+
+ var namesLookup = new Dictionary>();
+ string? line = null;
+ while ((line = reader.ReadLine()) != null)
+ {
+ if (line.Length == 0 || line[0] == '#')
+ {
+ continue; // ignore empty lines and comments
+ }
+
+ var names = line.Split(',');
+ foreach (var name in names)
+ {
+ HashSet? synonyms;
+ if (!namesLookup.TryGetValue(name, out synonyms))
+ {
+ synonyms = new HashSet();
+ namesLookup[name] = synonyms;
+ }
+
+ foreach (var altName in names)
+ {
+ // Don't add anything as a synonym of itself
+ if (altName != name)
+ {
+ synonyms.Add(altName);
+ }
+ }
+ }
+ }
+
+ foreach (var (name, synonyms) in namesLookup)
+ {
+ var nameSynonyms = await dbContext.NameSynonyms
+ .Where(ns => ns.Name == name)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ if (nameSynonyms == null)
+ {
+ nameSynonyms = new NameSynonyms
+ {
+ Name = name,
+ Synonyms = synonyms.ToArray(),
+ };
+
+ dbContext.NameSynonyms.Add(nameSynonyms);
+ }
+ else
+ {
+ nameSynonyms.Synonyms = synonyms.ToArray();
+ }
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulatePersonSearchAttributesJob.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulatePersonSearchAttributesJob.cs
new file mode 100644
index 000000000..a87512fae
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Jobs/PopulatePersonSearchAttributesJob.cs
@@ -0,0 +1,24 @@
+using Hangfire;
+using Microsoft.EntityFrameworkCore;
+using TeachingRecordSystem.Core.DataStore.Postgres;
+
+namespace TeachingRecordSystem.Core.Jobs;
+
+[AutomaticRetry(Attempts = 0)]
+public class PopulatePersonSearchAttributesJob(TrsDbContext dbContext)
+{
+ public async Task Execute(Guid personId)
+ {
+ var person = await dbContext.Persons.AsNoTracking().SingleAsync(p => p.PersonId == personId);
+ _ = await dbContext.Database.ExecuteSqlAsync(
+ $"""
+ CALL p_refresh_person_search_attributes(
+ {personId},
+ {person.FirstName},
+ {person.LastName},
+ {person.DateOfBirth},
+ {person.NationalInsuranceNumber},
+ {person.Trn})
+ """);
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Models/PersonSearchResult.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Models/PersonSearchResult.cs
new file mode 100644
index 000000000..d4f738eef
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Models/PersonSearchResult.cs
@@ -0,0 +1,12 @@
+namespace TeachingRecordSystem.Core.Models;
+
+public class PersonSearchResult
+{
+ public required Guid PersonId { get; set; }
+ public required string FirstName { get; set; }
+ public required string MiddleName { get; set; }
+ public required string LastName { get; set; }
+ public required DateOnly? DateOfBirth { get; set; }
+ public required string? Trn { get; set; }
+ public string? NationalInsuranceNumber { get; set; }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/IPersonSearchService.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/IPersonSearchService.cs
new file mode 100644
index 000000000..05c435e2f
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/IPersonSearchService.cs
@@ -0,0 +1,6 @@
+namespace TeachingRecordSystem.Core.Services.PersonSearch;
+
+public interface IPersonSearchService
+{
+ Task> Search(IEnumerable name, IEnumerable dateOfBirth, string? nino, string? trn);
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/PersonSearchService.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/PersonSearchService.cs
new file mode 100644
index 000000000..1dcd0e96d
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/PersonSearchService.cs
@@ -0,0 +1,58 @@
+using LinqKit;
+using Microsoft.EntityFrameworkCore;
+using TeachingRecordSystem.Core.DataStore.Postgres;
+using TeachingRecordSystem.Core.DataStore.Postgres.Models;
+
+namespace TeachingRecordSystem.Core.Services.PersonSearch;
+
+public class PersonSearchService(TrsDbContext dbContext) : IPersonSearchService
+{
+ public async Task> Search(IEnumerable name, IEnumerable dateOfBirth, string? nino, string? trn)
+ {
+ var fullNames = name.Select(n => (FirstName: n.First(), LastName: n.Where(n => n.Length > 1).LastOrDefault())).Where(n => n.LastName is not null).Select(n => $"{n.FirstName} {n.LastName}").ToList();
+ if (!fullNames.Any() || !dateOfBirth.Any() || (nino is null && trn is null))
+ {
+ return Array.Empty();
+ }
+
+ var searchResults = new List();
+ var dateOfBirthList = dateOfBirth.Select(d => d.ToString("yyyy-MM-dd")).ToList();
+
+ var searchPredicate = PredicateBuilder.New(false);
+ foreach (var fullName in fullNames)
+ {
+ searchPredicate = searchPredicate.Or(a => a.AttributeType == "FullName" && a.AttributeValue == fullName);
+ }
+
+ searchPredicate = searchPredicate.Or(a => a.AttributeType == "DateOfBirth" && dateOfBirthList.Contains(a.AttributeValue));
+
+ if (trn is not null)
+ {
+ searchPredicate = searchPredicate.Or(a => a.AttributeType == "Trn" && a.AttributeValue == trn);
+ }
+
+ if (nino is not null)
+ {
+ searchPredicate = searchPredicate.Or(a => a.AttributeType == "NationalInsuranceNumber" && a.AttributeValue == nino);
+ }
+
+ var results = await dbContext.PersonSearchAttributes
+ .Where(searchPredicate)
+ .GroupBy(a => a.PersonId)
+ .Where(g => g.Any(a => a.AttributeType == "FullName") && g.Any(a => a.AttributeType == "DateOfBirth") && (g.Any(a => a.AttributeType == "NationalInsuranceNumber") || g.Any(a => a.AttributeType == "Trn")))
+ .Select(g => dbContext.Persons.Where(p => p.PersonId == g.Key).ToList())
+ .ToListAsync();
+ var persons = results.SelectMany(p => p).ToArray();
+
+ return persons.Select(p => new PersonSearchResult
+ {
+ PersonId = p.PersonId,
+ FirstName = p.FirstName,
+ MiddleName = p.MiddleName,
+ LastName = p.LastName,
+ DateOfBirth = p.DateOfBirth,
+ Trn = p.Trn,
+ NationalInsuranceNumber = p.NationalInsuranceNumber
+ }).AsReadOnly();
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/ServiceCollectionExtensions.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..60bba47bd
--- /dev/null
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/PersonSearch/ServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace TeachingRecordSystem.Core.Services.PersonSearch;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddPersonSearch(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj b/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj
index ebfde4d4b..706437836 100644
--- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj
+++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj
@@ -53,6 +53,7 @@
+
diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Core.Tests/Services/PersonSearch/PersonSearchServiceTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Core.Tests/Services/PersonSearch/PersonSearchServiceTests.cs
new file mode 100644
index 000000000..2f0636f03
--- /dev/null
+++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Core.Tests/Services/PersonSearch/PersonSearchServiceTests.cs
@@ -0,0 +1,132 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.PowerPlatform.Dataverse.Client;
+using TeachingRecordSystem.Core.DataStore.Postgres.Models;
+using TeachingRecordSystem.Core.Dqt;
+using TeachingRecordSystem.Core.Services.PersonSearch;
+using TeachingRecordSystem.Core.Services.TrsDataSync;
+
+namespace TeachingRecordSystem.Core.Tests.Services.PersonSearch;
+
+public class PersonSearchServiceTests
+{
+ public PersonSearchServiceTests(
+ DbFixture dbFixture,
+ IOrganizationServiceAsync2 organizationService,
+ ReferenceDataCache referenceDataCache,
+ FakeTrnGenerator trnGenerator)
+ {
+ DbFixture = dbFixture;
+ Clock = new();
+
+ var dbContextFactory = dbFixture.GetDbContextFactory();
+
+ Helper = new TrsDataSyncHelper(
+ dbContextFactory,
+ organizationService,
+ referenceDataCache,
+ Clock);
+
+ TestData = new TestData(
+ dbContextFactory,
+ organizationService,
+ referenceDataCache,
+ Clock,
+ trnGenerator,
+ TestDataSyncConfiguration.Sync(Helper));
+ }
+
+ [Theory]
+ [InlineData(true, false, false, false)]
+ [InlineData(false, true, false, false)]
+ [InlineData(false, false, true, false)]
+ [InlineData(false, false, false, true)]
+ [InlineData(true, true, false, false)]
+ [InlineData(true, false, true, false)]
+ [InlineData(true, false, false, true)]
+ [InlineData(false, true, true, false)]
+ [InlineData(false, true, false, true)]
+ [InlineData(false, false, true, true)]
+ [InlineData(true, true, true, false)]
+ [InlineData(true, true, false, true)]
+ [InlineData(true, false, true, true)]
+ [InlineData(false, true, true, true)]
+ public Task Search_WithMissingParameters_ReturnsEmptyArray(bool hasNames, bool hasDatesOfBirth, bool hasNino, bool hasTrn) =>
+ DbFixture.WithDbContext(async dbContext =>
+ {
+ // Arrange
+ var names = hasNames ? [["John", "Doe"]] : Array.Empty();
+ var datesOfBirth = hasDatesOfBirth ? new[] { new DateOnly(1980, 1, 1) } : Array.Empty();
+ var nino = hasNino ? Faker.Identification.UkNationalInsuranceNumber() : null;
+ var trn = hasTrn ? await TestData.GenerateTrn() : null;
+
+ // Act
+ var personSearchService = new PersonSearchService(dbContext);
+ var results = await personSearchService.Search(names, datesOfBirth, nino, trn);
+
+ // Assert
+ Assert.Empty(results);
+ });
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ public Task Search_WithMatch_ReturnsResults(bool matchOnSynonym, bool matchOnNino) =>
+ DbFixture.WithDbContext(async dbContext =>
+ {
+ // Arrange
+ var firstName = "John";
+ var nickName = "Johnny";
+ dbContext.NameSynonyms.Add(new NameSynonyms
+ {
+ Name = firstName,
+ Synonyms = new[] { nickName },
+ });
+ await dbContext.SaveChangesAsync();
+
+ var person = await TestData.CreatePerson(b => b.WithFirstName(firstName).WithNationalInsuranceNumber());
+ var name = new[] { new[] { matchOnSynonym ? nickName : firstName, person.LastName } };
+ var dateOfBirth = new[] { person.DateOfBirth };
+ var nino = matchOnNino ? person.NationalInsuranceNumber : null;
+ var trn = matchOnNino ? null : person.Trn;
+
+ // Act
+ var personSearchService = new PersonSearchService(dbContext);
+ var results = await personSearchService.Search(name, dateOfBirth, nino, trn);
+
+ // Assert
+ Assert.NotEmpty(results);
+ Assert.Contains(results, r => r.PersonId == person.PersonId);
+ await dbContext.NameSynonyms.Where(ns => ns.Name == firstName).ExecuteDeleteAsync();
+ });
+
+ [Fact]
+ public Task Search_WithMatchForMultipleNames_ReturnsMultipleResults() =>
+ DbFixture.WithDbContext(async dbContext =>
+ {
+ // Arrange
+ var nationalInsuranceNumber = TestData.GenerateNationalInsuranceNumber();
+ var person1 = await TestData.CreatePerson(b => b.WithFirstName("John").WithLastName("Doe").WithNationalInsuranceNumber());
+ var person2 = await TestData.CreatePerson(b => b.WithFirstName("Jane").WithLastName("Doe"));
+ var name = new[] { new[] { "John", "Doe" }, new[] { "Jane", "Doe" } };
+ var dateOfBirth = new[] { person1.DateOfBirth, person2.DateOfBirth };
+
+ // Act
+ var personSearchService = new PersonSearchService(dbContext);
+ var results = await personSearchService.Search(name, dateOfBirth, person1.NationalInsuranceNumber, person2.Trn);
+
+ // Assert
+ Assert.NotEmpty(results);
+ Assert.Contains(results, r => r.PersonId == person1.PersonId);
+ Assert.Contains(results, r => r.PersonId == person2.PersonId);
+ });
+
+ private DbFixture DbFixture { get; }
+
+ private TestData TestData { get; }
+
+ private TestableClock Clock { get; }
+
+ public TrsDataSyncHelper Helper { get; }
+}
diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs
index 5ed6df6a8..73bb1b89d 100644
--- a/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs
+++ b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs
@@ -35,6 +35,7 @@ public class CreatePersonBuilder
private string? _mobileNumber;
private Contact_GenderCode? _gender;
private bool? _hasNationalInsuranceNumber;
+ private string? _nationalInsuranceNumber;
private readonly List _mandatoryQualifications = new();
private readonly List _qtsRegistrations = new();
private readonly List _sanctions = [];
@@ -158,14 +159,16 @@ public CreatePersonBuilder WithGender(Contact_GenderCode gender)
return this;
}
- public CreatePersonBuilder WithNationalInsuranceNumber(bool? hasNationalInsuranceNumber = true)
+ public CreatePersonBuilder WithNationalInsuranceNumber(bool? hasNationalInsuranceNumber = true, string? nationalInsuranceNumber = null)
{
- if (_hasNationalInsuranceNumber is not null && _hasNationalInsuranceNumber != hasNationalInsuranceNumber)
+ if ((_hasNationalInsuranceNumber is not null && _hasNationalInsuranceNumber != hasNationalInsuranceNumber)
+ || (_nationalInsuranceNumber is not null && _nationalInsuranceNumber != nationalInsuranceNumber))
{
throw new InvalidOperationException("WithNationalInsuranceNumber cannot be changed after it's set.");
}
_hasNationalInsuranceNumber = hasNationalInsuranceNumber;
+ _nationalInsuranceNumber = nationalInsuranceNumber;
return this;
}
@@ -226,7 +229,7 @@ internal async Task Execute(TestData testData)
if (_hasNationalInsuranceNumber ?? false)
{
- contact.dfeta_NINumber = testData.GenerateNationalInsuranceNumber();
+ contact.dfeta_NINumber = _nationalInsuranceNumber ?? testData.GenerateNationalInsuranceNumber();
}
var txnRequestBuilder = RequestBuilder.CreateTransaction(testData.OrganizationService);