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);