diff --git a/backend/webapi.tests/Features/EndorsementRequests/Approve.cs b/backend/webapi.tests/Features/EndorsementRequests/Approve.cs index b2c9782ae..8b4a5293a 100644 --- a/backend/webapi.tests/Features/EndorsementRequests/Approve.cs +++ b/backend/webapi.tests/Features/EndorsementRequests/Approve.cs @@ -12,12 +12,12 @@ namespace PidpTests.Features.EndorsementRequests; using PidpTests.TestingExtensions; -public class EndorsementApproveTests : InMemoryDbTest +public class EndorsementRequestApproveTests : InMemoryDbTest { - private static readonly int RequestingPartyId = 1; - private static readonly int ReceivingPartyId = 2; + private const int RequestingPartyId = 1; + private const int ReceivingPartyId = 2; - public EndorsementApproveTests() + public EndorsementRequestApproveTests() { this.TestDb.HasAParty(party => { @@ -41,7 +41,7 @@ public EndorsementApproveTests() [Theory] [MemberData(nameof(StatusCases))] - public async void Approve_AsRequestingParty_SuccessOnApproved(EndorsementRequestStatus status) + public async Task Approve_AsRequestingParty_SuccessOnApproved(EndorsementRequestStatus status) { var request = this.TestDb.Has(new EndorsementRequest { @@ -55,21 +55,20 @@ public async void Approve_AsRequestingParty_SuccessOnApproved(EndorsementRequest var result = await handler.HandleAsync(new Approve.Command { EndorsementRequestId = request.Id, PartyId = RequestingPartyId }); + Assert.Equal(expected, result.IsSuccess); if (expected) { - Assert.True(result.IsSuccess); Assert.Equal(EndorsementRequestStatus.Completed, request.Status); } else { - Assert.False(result.IsSuccess); Assert.Equal(status, request.Status); } } [Theory] [MemberData(nameof(StatusCases))] - public async void Approve_AsRecievingParty_SuccessOnRecieved(EndorsementRequestStatus status) + public async Task Approve_AsRecievingPartyNotPreApproved_SuccessOnRecieved(EndorsementRequestStatus status) { var request = this.TestDb.Has(new EndorsementRequest { @@ -82,29 +81,66 @@ public async void Approve_AsRecievingParty_SuccessOnRecieved(EndorsementRequestS var result = await handler.HandleAsync(new Approve.Command { EndorsementRequestId = request.Id, PartyId = ReceivingPartyId }); + Assert.Equal(expected, result.IsSuccess); if (expected) { - Assert.True(result.IsSuccess); Assert.Equal(EndorsementRequestStatus.Approved, request.Status); } else { - Assert.False(result.IsSuccess); Assert.Equal(status, request.Status); } } - public static IEnumerable StatusCases() + [Theory] + [MemberData(nameof(StatusCases))] + public async Task Approve_AsRecievingPartyPreApproved_CompleteOnRecieved(EndorsementRequestStatus status) { - foreach (var status in TestData.AllValuesOf()) + var request = this.TestDb.Has(new EndorsementRequest + { + RequestingPartyId = RequestingPartyId, + ReceivingPartyId = ReceivingPartyId, + Status = status, + PreApproved = true + }); + var expected = status == EndorsementRequestStatus.Received; // Reciever can only approve ER after recieving. + var handler = this.MockDependenciesFor(); + + var result = await handler.HandleAsync(new Approve.Command { EndorsementRequestId = request.Id, PartyId = ReceivingPartyId }); + + Assert.Equal(expected, result.IsSuccess); + if (expected) { - yield return new object[] { status }; + Assert.Equal(EndorsementRequestStatus.Completed, request.Status); + } + else + { + Assert.Equal(status, request.Status); } } + [Fact] + public async Task Approve_AsReceivingParty_SendEmailToRequestingParty() + { + var request = this.TestDb.Has(new EndorsementRequest + { + RequestingPartyId = RequestingPartyId, + ReceivingPartyId = ReceivingPartyId, + Status = EndorsementRequestStatus.Received, + RecipientEmail = "Email1@email.com" + }); + + var emailService = AMock.EmailService(); + var handler = this.MockDependenciesFor(emailService); + + var result = await handler.HandleAsync(new Approve.Command { EndorsementRequestId = request.Id, PartyId = ReceivingPartyId }); + Assert.True(result.IsSuccess); + A.CallTo(() => emailService.SendAsync(An._)).MustHaveHappenedOnceExactly(); + } + [Theory] [MemberData(nameof(MoaRoleTestCases))] - public async void Approve_AsRequester_MoaRoleAssigned(IEnumerable licencedParties, int? expectedRoleAssigned) + public async Task Approve_AsRequester_MoaRoleAssignedEmailSentToReceiver(IEnumerable licencedParties, int? expectedRoleAssigned) { foreach (var partyId in licencedParties) { @@ -122,7 +158,8 @@ public async void Approve_AsRequester_MoaRoleAssigned(IEnumerable licencedP .ReturningTrueWhenAssigingClientRoles(); var plrClient = A.Fake() .ReturningAStandingsDigestWhenCalledWithCpn("cpn", true); - var handler = this.MockDependenciesFor(keycloakClient, plrClient); + var emailService = AMock.EmailService(); + var handler = this.MockDependenciesFor(keycloakClient, plrClient, emailService); var result = await handler.HandleAsync(new Approve.Command { EndorsementRequestId = request.Id, PartyId = RequestingPartyId }); @@ -139,32 +176,63 @@ public async void Approve_AsRequester_MoaRoleAssigned(IEnumerable licencedP .Single(); A.CallTo(() => keycloakClient.AssignAccessRoles(expectedUserId, MohKeycloakEnrolment.MoaLicenceStatus)).MustHaveHappened(); } + Assert.Single(emailService.SentEmails); + var sentTo = emailService.SentEmails.Single().To.Single(); + Assert.Equal(this.TestDb.Parties.Single(party => party.Id == ReceivingPartyId).Email, sentTo); } - [Fact] - public async void Approve_AsReceivingParty_Send_Email_to_RequestingParty() + [Theory] + [MemberData(nameof(MoaRoleTestCases))] + public async Task Approve_AsPreApprovedReciever_MoaRoleAssignedEmailSentToRequester(IEnumerable licencedParties, int? expectedRoleAssigned) { + foreach (var partyId in licencedParties) + { + var party = this.TestDb.Parties.Single(party => party.Id == partyId); + party.Cpn = "cpn"; + } var request = this.TestDb.Has(new EndorsementRequest { RequestingPartyId = RequestingPartyId, ReceivingPartyId = ReceivingPartyId, Status = EndorsementRequestStatus.Received, - RecipientEmail = "Email1@email.com" + RecipientEmail = "name@example.com", + PreApproved = true }); - + var keycloakClient = A.Fake() + .ReturningTrueWhenAssigingClientRoles(); + var plrClient = A.Fake() + .ReturningAStandingsDigestWhenCalledWithCpn("cpn", true); var emailService = AMock.EmailService(); - var handler = this.MockDependenciesFor(emailService); + var handler = this.MockDependenciesFor(keycloakClient, plrClient, emailService); var result = await handler.HandleAsync(new Approve.Command { EndorsementRequestId = request.Id, PartyId = ReceivingPartyId }); + Assert.True(result.IsSuccess); - A.CallTo(() => emailService.SendAsync(An._)).MustHaveHappenedOnceExactly(); + if (expectedRoleAssigned == null) + { + keycloakClient.AssertNoRolesAssigned(); + } + else + { + var expectedUserId = this.TestDb.Parties + .Where(party => party.Id == expectedRoleAssigned) + .Select(party => party.PrimaryUserId) + .Single(); + A.CallTo(() => keycloakClient.AssignAccessRoles(expectedUserId, MohKeycloakEnrolment.MoaLicenceStatus)).MustHaveHappened(); + } + Assert.Single(emailService.SentEmails); + var sentTo = emailService.SentEmails.Single().To.Single(); + Assert.Equal(this.TestDb.Parties.Single(party => party.Id == RequestingPartyId).Email, sentTo); } - public static IEnumerable MoaRoleTestCases() + public static TheoryData StatusCases => new(TestData.AllValuesOf()); + + public static TheoryData MoaRoleTestCases => new() { - yield return new object[] { Enumerable.Empty(), null! }; // neither are licenced, no role assigned. - yield return new object[] { new[] { RequestingPartyId }, ReceivingPartyId }; - yield return new object[] { new[] { ReceivingPartyId }, RequestingPartyId }; - yield return new object[] { new[] { RequestingPartyId, ReceivingPartyId }, null! }; // Both are licenced, no role assigned - } + // { [ Licenced PartyIds ], PartyIds expected to have MOA role assigned } + { [], null }, // neither are licenced, no role assigned. + { [ RequestingPartyId ], ReceivingPartyId }, + { [ ReceivingPartyId ], RequestingPartyId }, + { [ RequestingPartyId, ReceivingPartyId ], null }, // Both are licenced, no role assigned + }; } diff --git a/backend/webapi.tests/Features/EndorsementRequests/Create.cs b/backend/webapi.tests/Features/EndorsementRequests/Create.cs new file mode 100644 index 000000000..20e18cedc --- /dev/null +++ b/backend/webapi.tests/Features/EndorsementRequests/Create.cs @@ -0,0 +1,121 @@ +namespace PidpTests.Features.EndorsementRequests; + +using FakeItEasy; +using Xunit; + +using Pidp.Features.EndorsementRequests; +using Pidp.Infrastructure.HttpClients.Mail; +using Pidp.Models; +using PidpTests.TestingExtensions; + +public class EndorsementRequestCreateTests : InMemoryDbTest +{ + [Fact] + public async Task Create_NotPreApproved_CreatedEmailSentToRecipient() + { + var requester = this.TestDb.HasAParty(); + var command = new Create.Command + { + PartyId = requester.Id, + RecipientEmail = "email@emailz.com", + AdditionalInformation = "addditional info", + PreApproved = false + }; + var emailService = AMock.EmailService(); + var handler = this.MockDependenciesFor(emailService); + + var result = await handler.HandleAsync(command); + + Assert.False(result.DuplicateEndorsementRequest); + + var endorsementRequest = this.TestDb.EndorsementRequests.SingleOrDefault(); + Assert.NotNull(endorsementRequest); + Assert.Equal(requester.Id, endorsementRequest.RequestingPartyId); + Assert.Equal(command.RecipientEmail, endorsementRequest.RecipientEmail); + Assert.Equal(command.AdditionalInformation, endorsementRequest.AdditionalInformation); + Assert.NotEqual(Guid.Empty, endorsementRequest.Token); + Assert.Equal(EndorsementRequestStatus.Created, endorsementRequest.Status); + Assert.False(endorsementRequest.PreApproved); + + A.CallTo(() => emailService.SendAsync(An._)).MustHaveHappenedOnceExactly(); + var email = emailService.SentEmails.Single(); + Assert.Single(email.To); + Assert.Equal(command.RecipientEmail, email.To.Single()); + Assert.Contains(endorsementRequest.Token.ToString(), email.Body); + } + + [Fact] + public async Task Create_PreApprovedOneMatchingEmailDifferentCase_RecievedEmailSentToRecipient() + { + var requester = this.TestDb.HasAParty(); + var reciever = this.TestDb.HasAParty(party => party.Email = "emailz@emal.com"); + var command = new Create.Command + { + PartyId = requester.Id, + RecipientEmail = "Emailz@eMal.com", + PreApproved = true + }; + var emailService = AMock.EmailService(); + var handler = this.MockDependenciesFor(emailService); + + var result = await handler.HandleAsync(command); + + Assert.False(result.DuplicateEndorsementRequest); + + var endorsementRequest = this.TestDb.EndorsementRequests.SingleOrDefault(); + Assert.NotNull(endorsementRequest); + Assert.Equal(requester.Id, endorsementRequest.RequestingPartyId); + Assert.Equal(reciever.Id, endorsementRequest.ReceivingPartyId); + Assert.Equal(command.RecipientEmail, endorsementRequest.RecipientEmail); + Assert.NotEqual(Guid.Empty, endorsementRequest.Token); + Assert.Equal(EndorsementRequestStatus.Received, endorsementRequest.Status); + Assert.True(endorsementRequest.PreApproved); + + A.CallTo(() => emailService.SendAsync(An._)).MustHaveHappenedOnceExactly(); + var email = emailService.SentEmails.Single(); + Assert.Single(email.To); + Assert.Equal(command.RecipientEmail, email.To.Single()); + Assert.DoesNotContain(endorsementRequest.Token.ToString(), email.Body); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + [InlineData(3)] + public async Task Create_PreApprovedZeroOrManyMatchingEmails_CreatedEmailSentToRecipient(int numberOfMatchingEmails) + { + var requester = this.TestDb.HasAParty(); + var recipientEmail = "email@mail.com"; + for (var i = 0; i < numberOfMatchingEmails; i++) + { + this.TestDb.HasAParty(party => party.Email = recipientEmail); + } + var command = new Create.Command + { + PartyId = requester.Id, + RecipientEmail = recipientEmail, + PreApproved = true + }; + var emailService = AMock.EmailService(); + var handler = this.MockDependenciesFor(emailService); + + var result = await handler.HandleAsync(command); + + Assert.False(result.DuplicateEndorsementRequest); + + var endorsementRequest = this.TestDb.EndorsementRequests.SingleOrDefault(); + Assert.NotNull(endorsementRequest); + Assert.Equal(requester.Id, endorsementRequest.RequestingPartyId); + Assert.Null(endorsementRequest.ReceivingPartyId); + Assert.Equal(command.RecipientEmail, endorsementRequest.RecipientEmail); + Assert.NotEqual(Guid.Empty, endorsementRequest.Token); + Assert.Equal(EndorsementRequestStatus.Created, endorsementRequest.Status); + Assert.False(endorsementRequest.PreApproved); + + A.CallTo(() => emailService.SendAsync(An._)).MustHaveHappenedOnceExactly(); + var email = emailService.SentEmails.Single(); + Assert.Single(email.To); + Assert.Equal(command.RecipientEmail, email.To.Single()); + Assert.Contains(endorsementRequest.Token.ToString(), email.Body); + } +} diff --git a/backend/webapi/Data/Migrations/20240731205921_PreApprovedEndorsement.Designer.cs b/backend/webapi/Data/Migrations/20240731205921_PreApprovedEndorsement.Designer.cs new file mode 100644 index 000000000..eae624bc0 --- /dev/null +++ b/backend/webapi/Data/Migrations/20240731205921_PreApprovedEndorsement.Designer.cs @@ -0,0 +1,1482 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pidp.Data; + +#nullable disable + +namespace Pidp.Data.Migrations +{ + [DbContext(typeof(PidpDbContext))] + [Migration("20240731205921_PreApprovedEndorsement")] + partial class PreApprovedEndorsement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Pidp.Models.AccessRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessTypeCode") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.Property("RequestedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PartyId"); + + b.ToTable("AccessRequest"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Pidp.Models.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("CountryCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("Postal") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProvinceCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Street") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("ProvinceCode"); + + b.ToTable("Address"); + + b.HasDiscriminator("Discriminator").HasValue("Address"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Pidp.Models.BusinessEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator("Discriminator").HasValue("BusinessEvent"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Pidp.Models.ClientLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInformation") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("LogLevel") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ClientLog"); + }); + + modelBuilder.Entity("Pidp.Models.Credential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IdpId") + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PartyId"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("\"UserId\" != '00000000-0000-0000-0000-000000000000'"); + + b.ToTable("Credential", t => + { + t.HasCheckConstraint("CHK_Credential_AtLeastOneIdentifier", "((\"UserId\" != '00000000-0000-0000-0000-000000000000') or (\"IdpId\" is not null))"); + }); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkErrorLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialLinkTicketId") + .HasColumnType("integer"); + + b.Property("ExistingCredentialId") + .HasColumnType("integer"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CredentialLinkTicketId"); + + b.HasIndex("ExistingCredentialId"); + + b.ToTable("CredentialLinkErrorLog"); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Claimed") + .HasColumnType("boolean"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkToIdentityProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.Property("Token") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PartyId"); + + b.ToTable("CredentialLinkTicket"); + }); + + modelBuilder.Entity("Pidp.Models.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Cc") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("DateSent") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestStatus") + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("MsgId") + .HasColumnType("uuid"); + + b.Property("SendType") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("Pidp.Models.Endorsement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Endorsement"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("EndorsementId") + .HasColumnType("integer"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EndorsementId"); + + b.HasIndex("PartyId"); + + b.ToTable("EndorsementRelationship"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInformation") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PreApproved") + .HasColumnType("boolean"); + + b.Property("ReceivingPartyId") + .HasColumnType("integer"); + + b.Property("RecipientEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestingPartyId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StatusDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReceivingPartyId"); + + b.HasIndex("RequestingPartyId"); + + b.ToTable("EndorsementRequest"); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.AccessType", b => + { + b.Property("Code") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("AccessTypeLookup"); + + b.HasData( + new + { + Code = 1, + Name = "Special Authority eForms" + }, + new + { + Code = 2, + Name = "HCIMWeb Account Transfer" + }, + new + { + Code = 3, + Name = "HCIMWeb Enrolment" + }, + new + { + Code = 4, + Name = "Driver Medical Fitness" + }, + new + { + Code = 5, + Name = "MS Teams for Clinical Use - Privacy Officer" + }, + new + { + Code = 6, + Name = "Prescription Refill eForm for Pharmacists" + }, + new + { + Code = 7, + Name = "Provider Reporting Portal" + }, + new + { + Code = 8, + Name = "MS Teams for Clinical Use - Clinic Member" + }, + new + { + Code = 9, + Name = "Access Harmonization User Access Agreement" + }, + new + { + Code = 10, + Name = "Immunization Entry eForm" + }); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.College", b => + { + b.Property("Code") + .HasColumnType("integer"); + + b.Property("Acronym") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("CollegeLookup"); + + b.HasData( + new + { + Code = 1, + Acronym = "CPSBC", + Name = "College of Physicians and Surgeons of BC" + }, + new + { + Code = 2, + Acronym = "CPBC", + Name = "College of Pharmacists of BC" + }, + new + { + Code = 3, + Acronym = "BCCNM", + Name = "BC College of Nurses and Midwives" + }, + new + { + Code = 4, + Acronym = "CNPBC", + Name = "College of Naturopathic Physicians of BC" + }, + new + { + Code = 5, + Acronym = "CDSBC", + Name = "College of Dental Surgeons of British Columbia" + }, + new + { + Code = 6, + Acronym = "COBC", + Name = "College of Optometrists of British Columbia" + }); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.Country", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("CountryLookup"); + + b.HasData( + new + { + Code = "CA", + Name = "Canada" + }, + new + { + Code = "US", + Name = "United States" + }); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.Province", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("CountryCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("ProvinceLookup"); + + b.HasData( + new + { + Code = "AB", + CountryCode = "CA", + Name = "Alberta" + }, + new + { + Code = "BC", + CountryCode = "CA", + Name = "British Columbia" + }, + new + { + Code = "MB", + CountryCode = "CA", + Name = "Manitoba" + }, + new + { + Code = "NB", + CountryCode = "CA", + Name = "New Brunswick" + }, + new + { + Code = "NL", + CountryCode = "CA", + Name = "Newfoundland and Labrador" + }, + new + { + Code = "NS", + CountryCode = "CA", + Name = "Nova Scotia" + }, + new + { + Code = "ON", + CountryCode = "CA", + Name = "Ontario" + }, + new + { + Code = "PE", + CountryCode = "CA", + Name = "Prince Edward Island" + }, + new + { + Code = "QC", + CountryCode = "CA", + Name = "Quebec" + }, + new + { + Code = "SK", + CountryCode = "CA", + Name = "Saskatchewan" + }, + new + { + Code = "NT", + CountryCode = "CA", + Name = "Northwest Territories" + }, + new + { + Code = "NU", + CountryCode = "CA", + Name = "Nunavut" + }, + new + { + Code = "YT", + CountryCode = "CA", + Name = "Yukon" + }, + new + { + Code = "AL", + CountryCode = "US", + Name = "Alabama" + }, + new + { + Code = "AK", + CountryCode = "US", + Name = "Alaska" + }, + new + { + Code = "AS", + CountryCode = "US", + Name = "American Samoa" + }, + new + { + Code = "AZ", + CountryCode = "US", + Name = "Arizona" + }, + new + { + Code = "AR", + CountryCode = "US", + Name = "Arkansas" + }, + new + { + Code = "CA", + CountryCode = "US", + Name = "California" + }, + new + { + Code = "CO", + CountryCode = "US", + Name = "Colorado" + }, + new + { + Code = "CT", + CountryCode = "US", + Name = "Connecticut" + }, + new + { + Code = "DE", + CountryCode = "US", + Name = "Delaware" + }, + new + { + Code = "DC", + CountryCode = "US", + Name = "District of Columbia" + }, + new + { + Code = "FL", + CountryCode = "US", + Name = "Florida" + }, + new + { + Code = "GA", + CountryCode = "US", + Name = "Georgia" + }, + new + { + Code = "GU", + CountryCode = "US", + Name = "Guam" + }, + new + { + Code = "HI", + CountryCode = "US", + Name = "Hawaii" + }, + new + { + Code = "ID", + CountryCode = "US", + Name = "Idaho" + }, + new + { + Code = "IL", + CountryCode = "US", + Name = "Illinois" + }, + new + { + Code = "IN", + CountryCode = "US", + Name = "Indiana" + }, + new + { + Code = "IA", + CountryCode = "US", + Name = "Iowa" + }, + new + { + Code = "KS", + CountryCode = "US", + Name = "Kansas" + }, + new + { + Code = "KY", + CountryCode = "US", + Name = "Kentucky" + }, + new + { + Code = "LA", + CountryCode = "US", + Name = "Louisiana" + }, + new + { + Code = "ME", + CountryCode = "US", + Name = "Maine" + }, + new + { + Code = "MD", + CountryCode = "US", + Name = "Maryland" + }, + new + { + Code = "MA", + CountryCode = "US", + Name = "Massachusetts" + }, + new + { + Code = "MI", + CountryCode = "US", + Name = "Michigan" + }, + new + { + Code = "MN", + CountryCode = "US", + Name = "Minnesota" + }, + new + { + Code = "MS", + CountryCode = "US", + Name = "Mississippi" + }, + new + { + Code = "MO", + CountryCode = "US", + Name = "Missouri" + }, + new + { + Code = "MT", + CountryCode = "US", + Name = "Montana" + }, + new + { + Code = "NE", + CountryCode = "US", + Name = "Nebraska" + }, + new + { + Code = "NV", + CountryCode = "US", + Name = "Nevada" + }, + new + { + Code = "NH", + CountryCode = "US", + Name = "New Hampshire" + }, + new + { + Code = "NJ", + CountryCode = "US", + Name = "New Jersey" + }, + new + { + Code = "NM", + CountryCode = "US", + Name = "New Mexico" + }, + new + { + Code = "NY", + CountryCode = "US", + Name = "New York" + }, + new + { + Code = "NC", + CountryCode = "US", + Name = "North Carolina" + }, + new + { + Code = "ND", + CountryCode = "US", + Name = "North Dakota" + }, + new + { + Code = "MP", + CountryCode = "US", + Name = "Northern Mariana Islands" + }, + new + { + Code = "OH", + CountryCode = "US", + Name = "Ohio" + }, + new + { + Code = "OK", + CountryCode = "US", + Name = "Oklahoma" + }, + new + { + Code = "OR", + CountryCode = "US", + Name = "Oregon" + }, + new + { + Code = "PA", + CountryCode = "US", + Name = "Pennsylvania" + }, + new + { + Code = "PR", + CountryCode = "US", + Name = "Puerto Rico" + }, + new + { + Code = "RI", + CountryCode = "US", + Name = "Rhode Island" + }, + new + { + Code = "SC", + CountryCode = "US", + Name = "South Carolina" + }, + new + { + Code = "SD", + CountryCode = "US", + Name = "South Dakota" + }, + new + { + Code = "TN", + CountryCode = "US", + Name = "Tennessee" + }, + new + { + Code = "TX", + CountryCode = "US", + Name = "Texas" + }, + new + { + Code = "UM", + CountryCode = "US", + Name = "United States Minor Outlying Islands" + }, + new + { + Code = "UT", + CountryCode = "US", + Name = "Utah" + }, + new + { + Code = "VT", + CountryCode = "US", + Name = "Vermont" + }, + new + { + Code = "VI", + CountryCode = "US", + Name = "Virgin Islands, U.S." + }, + new + { + Code = "VA", + CountryCode = "US", + Name = "Virginia" + }, + new + { + Code = "WA", + CountryCode = "US", + Name = "Washington" + }, + new + { + Code = "WV", + CountryCode = "US", + Name = "West Virginia" + }, + new + { + Code = "WI", + CountryCode = "US", + Name = "Wisconsin" + }, + new + { + Code = "WY", + CountryCode = "US", + Name = "Wyoming" + }); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyOfficerId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PrivacyOfficerId"); + + b.ToTable("MSTeamsClinic"); + }); + + modelBuilder.Entity("Pidp.Models.Party", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Birthdate") + .HasColumnType("date"); + + b.Property("Cpn") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("OpId") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("PreferredFirstName") + .HasColumnType("text"); + + b.Property("PreferredLastName") + .HasColumnType("text"); + + b.Property("PreferredMiddleName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OpId") + .IsUnique(); + + b.ToTable("Party"); + }); + + modelBuilder.Entity("Pidp.Models.PartyLicenceDeclaration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CollegeCode") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenceNumber") + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CollegeCode"); + + b.HasIndex("PartyId") + .IsUnique(); + + b.ToTable("PartyLicenceDeclaration"); + }); + + modelBuilder.Entity("Pidp.Models.PrpAuthorizedLicence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Claimed") + .HasColumnType("boolean"); + + b.Property("LicenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LicenceNumber") + .IsUnique(); + + b.ToTable("PrpAuthorizedLicence"); + }); + + modelBuilder.Entity("Pidp.Models.HcimAccountTransfer", b => + { + b.HasBaseType("Pidp.Models.AccessRequest"); + + b.Property("LdapUsername") + .IsRequired() + .HasColumnType("text"); + + b.ToTable("HcimAccountTransfer"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicMemberEnrolment", b => + { + b.HasBaseType("Pidp.Models.AccessRequest"); + + b.Property("ClinicId") + .HasColumnType("integer"); + + b.HasIndex("ClinicId"); + + b.ToTable("MSTeamsClinicMemberEnrolment"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicAddress", b => + { + b.HasBaseType("Pidp.Models.Address"); + + b.Property("ClinicId") + .HasColumnType("integer"); + + b.HasIndex("ClinicId") + .IsUnique(); + + b.ToTable("Address"); + + b.HasDiscriminator().HasValue("MSTeamsClinicAddress"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleAssigned", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("LicenceStatusRoleAssigned"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleUnassigned", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("LicenceStatusRoleUnassigned"); + }); + + modelBuilder.Entity("Pidp.Models.PartyNotInPlr", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("PartyNotInPlr"); + }); + + modelBuilder.Entity("Pidp.Models.AccessRequest", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany("AccessRequests") + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.Address", b => + { + b.HasOne("Pidp.Models.Lookups.Country", "Country") + .WithMany() + .HasForeignKey("CountryCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.Lookups.Province", "Province") + .WithMany() + .HasForeignKey("ProvinceCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Pidp.Models.Credential", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany("Credentials") + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkErrorLog", b => + { + b.HasOne("Pidp.Models.CredentialLinkTicket", "CredentialLinkTicket") + .WithMany() + .HasForeignKey("CredentialLinkTicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.Credential", "ExistingCredential") + .WithMany() + .HasForeignKey("ExistingCredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CredentialLinkTicket"); + + b.Navigation("ExistingCredential"); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkTicket", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRelationship", b => + { + b.HasOne("Pidp.Models.Endorsement", "Endorsement") + .WithMany("EndorsementRelationships") + .HasForeignKey("EndorsementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endorsement"); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRequest", b => + { + b.HasOne("Pidp.Models.Party", "ReceivingParty") + .WithMany() + .HasForeignKey("ReceivingPartyId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Pidp.Models.Party", "RequestingParty") + .WithMany() + .HasForeignKey("RequestingPartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReceivingParty"); + + b.Navigation("RequestingParty"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinic", b => + { + b.HasOne("Pidp.Models.Party", "PrivacyOfficer") + .WithMany() + .HasForeignKey("PrivacyOfficerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PrivacyOfficer"); + }); + + modelBuilder.Entity("Pidp.Models.PartyLicenceDeclaration", b => + { + b.HasOne("Pidp.Models.Lookups.College", "College") + .WithMany() + .HasForeignKey("CollegeCode"); + + b.HasOne("Pidp.Models.Party", "Party") + .WithOne("LicenceDeclaration") + .HasForeignKey("Pidp.Models.PartyLicenceDeclaration", "PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("College"); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.HcimAccountTransfer", b => + { + b.HasOne("Pidp.Models.AccessRequest", null) + .WithOne() + .HasForeignKey("Pidp.Models.HcimAccountTransfer", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicMemberEnrolment", b => + { + b.HasOne("Pidp.Models.MSTeamsClinic", "Clinic") + .WithMany() + .HasForeignKey("ClinicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.AccessRequest", null) + .WithOne() + .HasForeignKey("Pidp.Models.MSTeamsClinicMemberEnrolment", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Clinic"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicAddress", b => + { + b.HasOne("Pidp.Models.MSTeamsClinic", "Clinic") + .WithOne("Address") + .HasForeignKey("Pidp.Models.MSTeamsClinicAddress", "ClinicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Clinic"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleAssigned", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleUnassigned", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.PartyNotInPlr", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.Endorsement", b => + { + b.Navigation("EndorsementRelationships"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinic", b => + { + b.Navigation("Address") + .IsRequired(); + }); + + modelBuilder.Entity("Pidp.Models.Party", b => + { + b.Navigation("AccessRequests"); + + b.Navigation("Credentials"); + + b.Navigation("LicenceDeclaration"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/webapi/Data/Migrations/20240731205921_PreApprovedEndorsement.cs b/backend/webapi/Data/Migrations/20240731205921_PreApprovedEndorsement.cs new file mode 100644 index 000000000..9f2177d65 --- /dev/null +++ b/backend/webapi/Data/Migrations/20240731205921_PreApprovedEndorsement.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Pidp.Data.Migrations +{ + /// + public partial class PreApprovedEndorsement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PreApproved", + table: "EndorsementRequest", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PreApproved", + table: "EndorsementRequest"); + } + } +} diff --git a/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs b/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs index 3b029a117..b6002cb62 100644 --- a/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs +++ b/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs @@ -405,6 +405,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Modified") .HasColumnType("timestamp with time zone"); + b.Property("PreApproved") + .HasColumnType("boolean"); + b.Property("ReceivingPartyId") .HasColumnType("integer"); diff --git a/backend/webapi/Features/EndorsementRequests/Approve.cs b/backend/webapi/Features/EndorsementRequests/Approve.cs index 310259c42..a71facab3 100644 --- a/backend/webapi/Features/EndorsementRequests/Approve.cs +++ b/backend/webapi/Features/EndorsementRequests/Approve.cs @@ -30,33 +30,22 @@ public CommandValidator() } } - public class CommandHandler : ICommandHandler + public class CommandHandler( + IClock clock, + IEmailService emailService, + IKeycloakAdministrationClient keycloakClient, + ILogger logger, + IPlrClient plrClient, + PidpConfiguration config, + PidpDbContext context) : ICommandHandler { - private readonly IClock clock; - private readonly IEmailService emailService; - private readonly IKeycloakAdministrationClient keycloakClient; - private readonly ILogger logger; - private readonly IPlrClient plrClient; - private readonly PidpConfiguration config; - private readonly PidpDbContext context; - - public CommandHandler( - IClock clock, - IEmailService emailService, - IKeycloakAdministrationClient keycloakClient, - ILogger logger, - IPlrClient plrClient, - PidpConfiguration config, - PidpDbContext context) - { - this.clock = clock; - this.emailService = emailService; - this.keycloakClient = keycloakClient; - this.logger = logger; - this.plrClient = plrClient; - this.config = config; - this.context = context; - } + private readonly IClock clock = clock; + private readonly IEmailService emailService = emailService; + private readonly IKeycloakAdministrationClient keycloakClient = keycloakClient; + private readonly ILogger logger = logger; + private readonly IPlrClient plrClient = plrClient; + private readonly PidpConfiguration config = config; + private readonly PidpDbContext context = context; public async Task HandleAsync(Command command) { @@ -76,7 +65,7 @@ public async Task HandleAsync(Command command) if (endorsementRequest.Status == EndorsementRequestStatus.Approved) { - await this.SendEndorsementApprovedEmailAsync(endorsementRequest); + await this.SendEndorsementApprovedEmailAsync(endorsementRequest.RequestingPartyId); } if (endorsementRequest.Status == EndorsementRequestStatus.Completed) @@ -86,7 +75,9 @@ public async Task HandleAsync(Command command) await this.context.SaveChangesAsync(); // This double Save is deliberate; we need to persist the Endorsement Relationships in the database before we can calculate the EndorserData in the Domain Events. await this.HandleMoaUpdates(endorsementRequest); - await this.SendEndorsementCompletedEmailAsync(endorsementRequest); + await this.SendEndorsementCompletedEmailAsync(command.PartyId == endorsementRequest.RequestingPartyId + ? endorsementRequest.ReceivingPartyId!.Value + : endorsementRequest.RequestingPartyId); } await this.context.SaveChangesAsync(); @@ -144,10 +135,10 @@ private async Task AssignMoaRole(Party party) } } - private async Task SendEndorsementApprovedEmailAsync(EndorsementRequest request) + private async Task SendEndorsementApprovedEmailAsync(int requestingPartyId) { var requestingPartyEmail = await this.context.Parties - .Where(party => party.Id == request.RequestingPartyId) + .Where(party => party.Id == requestingPartyId) .Select(party => party.Email) .SingleAsync(); var applicationUrl = $"OneHealthID Service"; @@ -179,13 +170,12 @@ private async Task SendEndorsementApprovedEmailAsync(EndorsementRequest request)
Thank you."); await this.emailService.SendAsync(email); - } - private async Task SendEndorsementCompletedEmailAsync(EndorsementRequest request) + private async Task SendEndorsementCompletedEmailAsync(int recipientPartyId) { - var receivingPartyEmail = await this.context.Parties - .Where(party => party.Id == request.ReceivingPartyId) + var partyEmail = await this.context.Parties + .Where(party => party.Id == recipientPartyId) .Select(party => party.Email) .SingleAsync(); var applicationUrl = $"OneHealthID Service"; @@ -194,7 +184,7 @@ private async Task SendEndorsementCompletedEmailAsync(EndorsementRequest request var email = new Email( from: EmailService.PidpEmail, - to: receivingPartyEmail!, + to: partyEmail!, subject: $"OneHealthID Endorsement is approved", body: $@"Hello,
You are receiving this email because your endorsement is now complete in the {applicationUrl}. You should now be able to access and use the Provincial Attachment System. You can now continue enrolling in any services that required endorsement from a licensed provider. diff --git a/backend/webapi/Features/EndorsementRequests/Create.cs b/backend/webapi/Features/EndorsementRequests/Create.cs index c5ecee78a..70ee28054 100644 --- a/backend/webapi/Features/EndorsementRequests/Create.cs +++ b/backend/webapi/Features/EndorsementRequests/Create.cs @@ -21,6 +21,7 @@ public class Command : ICommand public int PartyId { get; set; } public string RecipientEmail { get; set; } = string.Empty; public string? AdditionalInformation { get; set; } + public bool PreApproved { get; set; } } public class Model @@ -41,24 +42,16 @@ public CommandValidator() } } - public class CommandHandler : ICommandHandler + public class CommandHandler( + IClock clock, + IEmailService emailService, + PidpConfiguration config, + PidpDbContext context) : ICommandHandler { - private readonly string applicationUrl; - private readonly IClock clock; - private readonly IEmailService emailService; - private readonly PidpDbContext context; - - public CommandHandler( - IClock clock, - IEmailService emailService, - PidpConfiguration config, - PidpDbContext context) - { - this.applicationUrl = config.ApplicationUrl; - this.clock = clock; - this.emailService = emailService; - this.context = context; - } + private readonly string applicationUrl = config.ApplicationUrl; + private readonly IClock clock = clock; + private readonly IEmailService emailService = emailService; + private readonly PidpDbContext context = context; public async Task HandleAsync(Command command) { @@ -75,11 +68,6 @@ public async Task HandleAsync(Command command) return new(true); } - var partyName = await this.context.Parties - .Where(party => party.Id == command.PartyId) - .Select(party => party.FullName) - .SingleAsync(); - var request = new EndorsementRequest { RequestingPartyId = command.PartyId, @@ -90,15 +78,34 @@ public async Task HandleAsync(Command command) StatusDate = this.clock.GetCurrentInstant() }; + if (command.PreApproved) + { + var possibleRecipients = await this.context.Parties + .Where(party => party.Email.ToLower() == command.RecipientEmail.ToLower()) + .Select(party => party.Id) + .ToListAsync(); + + if (possibleRecipients.Count == 1) + { + request.ReceivingPartyId = possibleRecipients.Single(); + request.Status = EndorsementRequestStatus.Received; + request.PreApproved = true; + } + } + this.context.EndorsementRequests.Add(request); await this.context.SaveChangesAsync(); - await this.SendEndorsementRequestEmailAsync(request.RecipientEmail, request.Token, partyName); + var requestingPartyName = await this.context.Parties + .Where(party => party.Id == command.PartyId) + .Select(party => party.FullName) + .SingleAsync(); + await this.SendEndorsementRequestEmailAsync(request.RecipientEmail, request.PreApproved ? null : request.Token, requestingPartyName); return new(false); } - private async Task SendEndorsementRequestEmailAsync(string recipientEmail, Guid token, string partyName) + private async Task SendEndorsementRequestEmailAsync(string recipientEmail, Guid? token, string partyName) { string url = this.applicationUrl.SetQueryParam("endorsement-token", token); var link = $"this link"; diff --git a/backend/webapi/Features/EndorsementRequests/EmailSearch.cs b/backend/webapi/Features/EndorsementRequests/EmailSearch.cs new file mode 100644 index 000000000..b82fe605f --- /dev/null +++ b/backend/webapi/Features/EndorsementRequests/EmailSearch.cs @@ -0,0 +1,47 @@ +namespace Pidp.Features.EndorsementRequests; + +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +using Pidp.Data; + + +public class EmailSearch +{ + public class Query : IQuery + { + public string RecipientEmail { get; set; } = string.Empty; + } + + public class Model + { + public string? RecipientName { get; set; } + } + + public class QueryValidator : AbstractValidator + { + public QueryValidator() => this.RuleFor(x => x.RecipientEmail).NotEmpty().EmailAddress(); + } + + public class QueryHandler(PidpDbContext context) : IQueryHandler + { + private readonly PidpDbContext context = context; + + public async Task HandleAsync(Query query) + { + var existingParties = await this.context.Parties + .Where(party => party.Email!.ToLower() == query.RecipientEmail.ToLower()) + .Select(party => party.DisplayFullName) + .ToListAsync(); + + if (existingParties.Count == 1) + { + return new Model { RecipientName = existingParties.Single() }; + } + else + { + return new(); + } + } + } +} diff --git a/backend/webapi/Features/EndorsementRequests/EndorsementRequestsController.cs b/backend/webapi/Features/EndorsementRequests/EndorsementRequestsController.cs index 00987f35a..4a5c6eb5e 100644 --- a/backend/webapi/Features/EndorsementRequests/EndorsementRequestsController.cs +++ b/backend/webapi/Features/EndorsementRequests/EndorsementRequestsController.cs @@ -11,10 +11,8 @@ namespace Pidp.Features.EndorsementRequests; [Route("api/parties/{partyId}/[controller]")] [Authorize(Policy = Policies.AnyPartyIdentityProvider)] -public class EndorsementRequestsController : PidpControllerBase +public class EndorsementRequestsController(IPidpAuthorizationService authorizationService) : PidpControllerBase(authorizationService) { - public EndorsementRequestsController(IPidpAuthorizationService authorizationService) : base(authorizationService) { } - [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -24,6 +22,13 @@ public EndorsementRequestsController(IPidpAuthorizationService authorizationServ => await this.AuthorizePartyBeforeHandleAsync(query.PartyId, handler, query) .ToActionResultOfT(); + [HttpPost("email-search")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> EmailSearch([FromServices] IQueryHandler handler, + [FromBody] EmailSearch.Query query) + => await handler.HandleAsync(query); + [HttpPost] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/backend/webapi/Models/EndorsementRequest.cs b/backend/webapi/Models/EndorsementRequest.cs index d8d5316b1..54bd20729 100644 --- a/backend/webapi/Models/EndorsementRequest.cs +++ b/backend/webapi/Models/EndorsementRequest.cs @@ -42,11 +42,15 @@ public class EndorsementRequest : BaseAuditable public Instant StatusDate { get; set; } + public bool PreApproved { get; set; } + public IDomainResult Handle(Approve.Command command, IClock clock) { if (this.ActionableByReciever(command.PartyId)) { - this.Status = EndorsementRequestStatus.Approved; + this.Status = this.PreApproved + ? EndorsementRequestStatus.Completed + : EndorsementRequestStatus.Approved; } else if (this.ActionableByRequester(command.PartyId)) { diff --git a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements-resource.service.ts b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements-resource.service.ts index 574da0daa..b76695ef7 100644 --- a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements-resource.service.ts +++ b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements-resource.service.ts @@ -11,6 +11,7 @@ import { ToastService } from '@app/core/services/toast.service'; import { EndorsementRequestInformation } from './models/endorsement-request-information.model'; import { EndorsementRequest } from './models/endorsement-request.model'; import { Endorsement } from './models/endorsement.model'; +import { EndorsementEmailSearch } from './models/endorsement-email-search.model'; @Injectable({ providedIn: 'root', @@ -19,7 +20,7 @@ export class EndorsementsResource { public constructor( private apiResource: ApiHttpClient, private toastService: ToastService, - ) {} + ) { } public getEndorsements(partyId: number): Observable { return this.apiResource @@ -69,6 +70,23 @@ export class EndorsementsResource { ); } + public emailSearch( + partyId: number, + recipientEmail: string, + ): Observable { + return this.apiResource + .post( + `${this.getRequestsResourcePath(partyId)}/email-search`, + { recipientEmail }, + ) + .pipe( + catchError(() => + + of({ recipientName: null } as EndorsementEmailSearch), + ), + ); + } + public createEndorsementRequest( partyId: number, endorsementRequest: EndorsementRequestInformation, @@ -133,10 +151,10 @@ export class EndorsementsResource { .pipe( NoContentResponse, tap(() => - this.toastService.openSuccessToast( - 'Endorsement Request has been approved successfully', + this.toastService.openSuccessToast( + 'Endorsement Request has been approved successfully', + ), ), - ), catchError((error: HttpErrorResponse) => { if (error.status === HttpStatusCode.BadRequest) { return of(void 0); diff --git a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.html b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.html index 31ed383ba..d9a7a1d12 100644 --- a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.html +++ b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.html @@ -163,7 +163,10 @@

Endorsement Request

Email - + Required diff --git a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.ts b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.ts index 3add699b4..3568cde0b 100644 --- a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.ts +++ b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/endorsements.page.ts @@ -58,6 +58,7 @@ import { LoggerService } from '@app/core/services/logger.service'; import { UtilsService } from '@app/core/services/utils.service'; import { StatusCode } from '@app/features/portal/enums/status-code.enum'; import { LookupService } from '@app/modules/lookup/lookup.service'; +import { BreadcrumbComponent } from '@app/shared/components/breadcrumb/breadcrumb.component'; import { EndorsementCardComponent } from './components/endorsement-card/endorsement-card.component'; import { @@ -67,9 +68,9 @@ import { import { EndorsementsFormState } from './endorsements-form-state'; import { EndorsementsResource } from './endorsements-resource.service'; import { EndorsementRequestStatus } from './enums/endorsement-request-status.enum'; +import { EndorsementEmailSearch } from './models/endorsement-email-search.model'; import { EndorsementRequest } from './models/endorsement-request.model'; import { Endorsement } from './models/endorsement.model'; -import { BreadcrumbComponent } from '@app/shared/components/breadcrumb/breadcrumb.component'; export enum EndorsementType { WorkingRelationship, @@ -116,6 +117,7 @@ export class EndorsementsPage public endorsements$!: Observable; public showOverlayOnSubmit = true; + public isDialogOpen = false; public constructor( dependenciesService: AbstractFormDependenciesService, @@ -166,6 +168,18 @@ export class EndorsementsPage class: 'dialog-container', }; + public onEnter(event: Event): void { + event.preventDefault(); + if (!this.isDialogOpen) { + const sendButton = document.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement; + if (sendButton) { + sendButton.click(); + } + } + } + public get recipientEmail(): FormControl { return this.formState.form.get('recipientEmail') as FormControl; } @@ -193,10 +207,13 @@ export class EndorsementsPage 'You are about to approve this endorsement, would you like to proceed', }; + this.isDialogOpen = true; + this.dialog .open(ConfirmDialogComponent, { data }) .afterClosed() .pipe( + tap(() => (this.isDialogOpen = false)), exhaustMap((result) => { this.loadingOverlayService.close(); return result @@ -223,10 +240,12 @@ export class EndorsementsPage content: 'You are about to cancel this endorsement, would you like to proceed', }; + this.isDialogOpen = true; this.dialog .open(ConfirmDialogComponent, { data }) .afterClosed() .pipe( + tap(() => (this.isDialogOpen = false)), exhaustMap((result) => { this.loadingOverlayService.close(); return result @@ -253,10 +272,12 @@ export class EndorsementsPage content: 'You are about to cancel this endorsement, would you like to proceed', }; + this.isDialogOpen = true; this.dialog .open(ConfirmDialogComponent, { data }) .afterClosed() .pipe( + tap(() => (this.isDialogOpen = false)), exhaustMap((result) => result ? this.resource @@ -321,6 +342,7 @@ export class EndorsementsPage protected performSubmission(): NoContent { const partyId = this.partyService.partyId; + const data: DialogOptions = { title: 'Endorsement requests', bottomBorder: false, @@ -328,10 +350,7 @@ export class EndorsementsPage bodyTextPosition: 'center', component: HtmlComponent, data: { - content: - "You are about to request an endorsement to

" + - this.formState.json?.recipientEmail + - '

would you like to proceed?', + content: '', }, imageSrc: '/assets/images/online-marketing-hIgeoQjS_iE-unsplash.jpg', imageType: 'banner', @@ -342,26 +361,38 @@ export class EndorsementsPage class: 'dialog-container', }; + this.isDialogOpen = true; return partyId && this.formState.json - ? this.dialog - .open(ConfirmDialogComponent, { data }) - .afterClosed() + ? this.resource + .emailSearch(partyId, this.formState.json.recipientEmail) .pipe( - exhaustMap((result) => { - this.loadingOverlayService.close(); - return result && partyId && this.formState.json - ? this.resource.createEndorsementRequest( - partyId, - this.formState.json, - ) - : EMPTY; - }), - catchError((error: HttpErrorResponse) => { - this.loadingOverlayService.close(); - if (error.status === HttpStatusCode.BadRequest) { - return of(noop()); - } - return of(noop()); + switchMap((response: EndorsementEmailSearch) => { + data.data!.content = response.recipientName + ? `An existing user has registered ${this.formState.json?.recipientEmail}. Please confirm you are wanting an endorsement with

${response.recipientName}

` + : `You are about to request an endorsement to

${this.formState.json?.recipientEmail}

would you like to proceed?`; + + return this.dialog + .open(ConfirmDialogComponent, { data }) + .afterClosed() + .pipe( + tap(() => (this.isDialogOpen = false)), + exhaustMap((result) => { + this.loadingOverlayService.close(); + return result && partyId && this.formState.json + ? this.resource.createEndorsementRequest(partyId, { + ...this.formState.json, + preApproved: response.recipientName ? true : false, + }) + : EMPTY; + }), + catchError((error: HttpErrorResponse) => { + this.loadingOverlayService.close(); + if (error.status === HttpStatusCode.BadRequest) { + return of(noop()); + } + return of(noop()); + }), + ); }), ) : EMPTY; diff --git a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-email-search.model.ts b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-email-search.model.ts new file mode 100644 index 000000000..90578573a --- /dev/null +++ b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-email-search.model.ts @@ -0,0 +1,3 @@ +export interface EndorsementEmailSearch { + recipientName: string | null; +} diff --git a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request-information.model.ts b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request-information.model.ts index 5a68bfbd6..1e565281c 100644 --- a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request-information.model.ts +++ b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request-information.model.ts @@ -4,5 +4,5 @@ import { EndorsementRequest } from './endorsement-request.model'; export interface EndorsementRequestInformation extends Pick< EndorsementRequest, - 'recipientEmail' | 'additionalInformation' - > {} + 'recipientEmail' | 'additionalInformation' | 'preApproved' + > { } diff --git a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request.model.ts b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request.model.ts index 6681682f9..039713885 100644 --- a/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request.model.ts +++ b/workspace/apps/pidp/src/app/features/organization-info/pages/endorsements/models/endorsement-request.model.ts @@ -9,4 +9,5 @@ export interface EndorsementRequest { status: EndorsementRequestStatus; statusDate: string; actionable: boolean; + preApproved: boolean; }