diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs index 671c49736..3db1d7dcd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs @@ -7,8 +7,9 @@ namespace Unity.GrantManager.ApplicantProfile; /// /// Provides applicant-profile-specific contact retrieval operations. -/// This service aggregates contacts from two sources: profile-linked contacts -/// and application-level contacts matched by OIDC subject. +/// This service aggregates contacts from three sources: profile-linked contacts, +/// application-level contacts matched by OIDC subject, and applicant agent +/// contacts derived from the submission login token. /// public interface IApplicantProfileContactService { @@ -26,4 +27,13 @@ public interface IApplicantProfileContactService /// The OIDC subject identifier (e.g. "user@idir"). /// A list of with IsEditable set to false. Task> GetApplicationContactsBySubjectAsync(string subject); + + /// + /// Retrieves contacts derived from applicant agents on applications whose form submissions + /// match the given OIDC subject. The join path is Submission → Application → ApplicantAgent. + /// The subject is normalized by stripping the domain portion (after @) and converting to upper case. + /// + /// The OIDC subject identifier (e.g. "user@idir"). + /// A list of with IsEditable set to false. + Task> GetApplicantAgentContactsBySubjectAsync(string subject); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs index 2be1b4ed0..112eed817 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs @@ -17,5 +17,6 @@ public class ContactInfoItemDto public bool IsPrimary { get; set; } public bool IsEditable { get; set; } public Guid? ApplicationId { get; set; } + public string? ReferenceNo { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs index 46ef6e66f..eba51fa13 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs @@ -13,15 +13,18 @@ namespace Unity.GrantManager.ApplicantProfile; /// -/// Applicant-profile-specific contact service. Retrieves contacts linked to applicant profiles -/// and application-level contacts matched by OIDC subject. This service operates independently -/// from the generic and queries repositories directly. +/// Applicant-profile-specific contact service. Retrieves contacts linked to applicant profiles, +/// application-level contacts matched by OIDC subject, and applicant agent contacts derived from +/// the submission login token. This service operates independently from the generic +/// and queries repositories directly. /// public class ApplicantProfileContactService( IContactRepository contactRepository, IContactLinkRepository contactLinkRepository, IRepository applicationFormSubmissionRepository, - IRepository applicationContactRepository) + IRepository applicationContactRepository, + IRepository applicantAgentRepository, + IRepository applicationRepository) : IApplicantProfileContactService, ITransientDependency { private const string ApplicantProfileEntityType = "ApplicantProfile"; @@ -52,24 +55,23 @@ join contact in contactsQuery on link.ContactId equals contact.Id Role = link.Role, IsPrimary = link.IsPrimary, IsEditable = true, - ApplicationId = null + ApplicationId = null, + ReferenceNo = null }).ToListAsync(); } /// public async Task> GetApplicationContactsBySubjectAsync(string subject) { - var normalizedSubject = subject.Contains('@') - ? subject[..subject.IndexOf('@')].ToUpperInvariant() - : subject.ToUpperInvariant(); - var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); var applicationContactsQuery = await applicationContactRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); var applicationContacts = await ( from submission in submissionsQuery join appContact in applicationContactsQuery on submission.ApplicationId equals appContact.ApplicationId - where submission.OidcSub == normalizedSubject + join application in applicationsQuery on submission.ApplicationId equals application.Id + where submission.OidcSub == subject select new ContactInfoItemDto { ContactId = appContact.Id, @@ -82,12 +84,45 @@ join appContact in applicationContactsQuery on submission.ApplicationId equals a ContactType = "Application", IsPrimary = false, IsEditable = false, - ApplicationId = appContact.ApplicationId + ApplicationId = appContact.ApplicationId, + ReferenceNo = application.ReferenceNo }).ToListAsync(); return applicationContacts; } + /// + public async Task> GetApplicantAgentContactsBySubjectAsync(string subject) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var agentsQuery = await applicantAgentRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var agentContacts = await ( + from submission in submissionsQuery + join agent in agentsQuery on submission.ApplicationId equals agent.ApplicationId + join application in applicationsQuery on submission.ApplicationId equals application.Id + where submission.OidcSub == subject + select new ContactInfoItemDto + { + ContactId = agent.Id, + Name = agent.Name, + Title = agent.Title, + Email = agent.Email, + WorkPhoneNumber = agent.Phone, + WorkPhoneExtension = agent.PhoneExtension, + MobilePhoneNumber = agent.Phone2, + Role = agent.RoleForApplicant, + ContactType = "ApplicantAgent", + IsPrimary = false, + IsEditable = false, + ApplicationId = agent.ApplicationId, + ReferenceNo = application.ReferenceNo + }).ToListAsync(); + + return agentContacts; + } + private static string GetMatchingRole(string contactType) { return ApplicationContactOptionList.ContactTypeList.TryGetValue(contactType, out string? value) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs index 13bd414ee..e028bb1b2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs @@ -7,7 +7,7 @@ namespace Unity.GrantManager.ApplicantProfile { /// /// Provides contact information for the applicant profile by aggregating - /// profile-linked contacts and application-level contacts. + /// profile-linked contacts, application-level contacts, and applicant agent contacts. /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class ContactInfoDataProvider( @@ -33,8 +33,15 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ var profileContacts = await applicantProfileContactService.GetProfileContactsAsync(request.ProfileId); dto.Contacts.AddRange(profileContacts); - var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject); + var normalizedSubject = request.Subject.Contains('@') + ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() + : request.Subject.ToUpperInvariant(); + + var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(normalizedSubject); dto.Contacts.AddRange(applicationContacts); + + var agentContacts = await applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync(normalizedSubject); + dto.Contacts.AddRange(agentContacts); } return dto; diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index cc8d41a68..7d7c20fc7 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -34,6 +34,8 @@ private static ContactInfoDataProvider CreateContactInfoDataProvider() .Returns(Task.FromResult(new List())); applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) .Returns(Task.FromResult(new List())); + applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync(Arg.Any()) + .Returns(Task.FromResult(new List())); return new ContactInfoDataProvider(currentTenant, applicantProfileContactService); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs index 976ad574c..dde051f27 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs @@ -22,6 +22,14 @@ public ContactInfoDataProviderTests() _currentTenant = Substitute.For(); _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); _applicantProfileContactService = Substitute.For(); + + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + _provider = new ContactInfoDataProvider(_currentTenant, _applicantProfileContactService); } @@ -38,10 +46,6 @@ public async Task GetDataAsync_ShouldChangeTenant() { // Arrange var request = CreateRequest(); - _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) - .Returns(new List()); - _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) - .Returns(new List()); // Act await _provider.GetDataAsync(request); @@ -55,10 +59,6 @@ public async Task GetDataAsync_ShouldCallGetProfileContactsWithProfileId() { // Arrange var request = CreateRequest(); - _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) - .Returns(new List()); - _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) - .Returns(new List()); // Act await _provider.GetDataAsync(request); @@ -72,20 +72,29 @@ public async Task GetDataAsync_ShouldCallGetApplicationContactsWithSubject() { // Arrange var request = CreateRequest(); - _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) - .Returns(new List()); - _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) - .Returns(new List()); // Act await _provider.GetDataAsync(request); // Assert - await _applicantProfileContactService.Received(1).GetApplicationContactsBySubjectAsync(request.Subject); + await _applicantProfileContactService.Received(1).GetApplicationContactsBySubjectAsync("TESTUSER"); } [Fact] - public async Task GetDataAsync_ShouldCombineBothContactSets() + public async Task GetDataAsync_ShouldCallGetApplicantAgentContactsWithSubject() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetApplicantAgentContactsBySubjectAsync("TESTUSER"); + } + + [Fact] + public async Task GetDataAsync_ShouldCombineAllContactSets() { // Arrange var request = CreateRequest(); @@ -98,17 +107,22 @@ public async Task GetDataAsync_ShouldCombineBothContactSets() { new() { ContactId = Guid.NewGuid(), Name = "App Contact 1", IsEditable = false } }; + var agentContacts = new List + { + new() { ContactId = Guid.NewGuid(), Name = "Agent Contact 1", IsEditable = false, ContactType = "ApplicantAgent" } + }; _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId).Returns(profileContacts); - _applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject).Returns(appContacts); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync("TESTUSER").Returns(appContacts); + _applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync("TESTUSER").Returns(agentContacts); // Act var result = await _provider.GetDataAsync(request); // Assert var dto = result.ShouldBeOfType(); - dto.Contacts.Count.ShouldBe(3); + dto.Contacts.Count.ShouldBe(4); dto.Contacts.Count(c => c.IsEditable).ShouldBe(2); - dto.Contacts.Count(c => !c.IsEditable).ShouldBe(1); + dto.Contacts.Count(c => !c.IsEditable).ShouldBe(2); } [Fact] @@ -116,10 +130,6 @@ public async Task GetDataAsync_WithNoContacts_ShouldReturnEmptyList() { // Arrange var request = CreateRequest(); - _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) - .Returns(new List()); - _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) - .Returns(new List()); // Act var result = await _provider.GetDataAsync(request); @@ -130,7 +140,7 @@ public async Task GetDataAsync_WithNoContacts_ShouldReturnEmptyList() } [Fact] - public async Task GetDataAsync_ProfileContactsShouldAppearBeforeApplicationContacts() + public async Task GetDataAsync_ContactsShouldAppearInExpectedOrder() { // Arrange var request = CreateRequest(); @@ -146,10 +156,19 @@ public async Task GetDataAsync_ProfileContactsShouldAppearBeforeApplicationConta Name = "App Second", IsEditable = false }; + var agentContact = new ContactInfoItemDto + { + ContactId = Guid.NewGuid(), + Name = "Agent Third", + IsEditable = false, + ContactType = "ApplicantAgent" + }; _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId) .Returns(new List { profileContact }); - _applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject) + _applicantProfileContactService.GetApplicationContactsBySubjectAsync("TESTUSER") .Returns(new List { appContact }); + _applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync("TESTUSER") + .Returns(new List { agentContact }); // Act var result = await _provider.GetDataAsync(request); @@ -158,6 +177,7 @@ public async Task GetDataAsync_ProfileContactsShouldAppearBeforeApplicationConta var dto = result.ShouldBeOfType(); dto.Contacts[0].Name.ShouldBe("Profile First"); dto.Contacts[1].Name.ShouldBe("App Second"); + dto.Contacts[2].Name.ShouldBe("Agent Third"); } [Fact] @@ -165,10 +185,6 @@ public async Task GetDataAsync_ShouldReturnCorrectDataType() { // Arrange var request = CreateRequest(); - _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) - .Returns(new List()); - _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) - .Returns(new List()); // Act var result = await _provider.GetDataAsync(request); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs index 48fadd7b9..874f16fd3 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs @@ -18,6 +18,8 @@ public class ApplicantProfileContactServiceTests private readonly IContactLinkRepository _contactLinkRepository; private readonly IRepository _submissionRepository; private readonly IRepository _applicationContactRepository; + private readonly IRepository _applicantAgentRepository; + private readonly IRepository _applicationRepository; private readonly ApplicantProfileContactService _service; public ApplicantProfileContactServiceTests() @@ -26,12 +28,16 @@ public ApplicantProfileContactServiceTests() _contactLinkRepository = Substitute.For(); _submissionRepository = Substitute.For>(); _applicationContactRepository = Substitute.For>(); + _applicantAgentRepository = Substitute.For>(); + _applicationRepository = Substitute.For>(); _service = new ApplicantProfileContactService( _contactRepository, _contactLinkRepository, _submissionRepository, - _applicationContactRepository); + _applicationContactRepository, + _applicantAgentRepository, + _applicationRepository); } private static T WithId(T entity, Guid id) where T : Entity @@ -143,11 +149,20 @@ public async Task GetApplicationContactsBySubjectAsync_WithMatchingSubmission_Sh }, appContactId) }.AsAsyncQueryable(); + var applications = new[] + { + WithId(new Application + { + ReferenceNo = "REF-001" + }, applicationId) + }.AsAsyncQueryable(); + _submissionRepository.GetQueryableAsync().Returns(submissions); _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + _applicationRepository.GetQueryableAsync().Returns(applications); // Act - var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + var result = await _service.GetApplicationContactsBySubjectAsync("TESTUSER"); // Assert result.Count.ShouldBe(1); @@ -163,10 +178,11 @@ public async Task GetApplicationContactsBySubjectAsync_WithMatchingSubmission_Sh contact.IsPrimary.ShouldBeFalse(); contact.IsEditable.ShouldBeFalse(); contact.ApplicationId.ShouldBe(applicationId); + contact.ReferenceNo.ShouldBe("REF-001"); } [Fact] - public async Task GetApplicationContactsBySubjectAsync_ShouldMatchCaseInsensitively() + public async Task GetApplicationContactsBySubjectAsync_WithNonMatchingSubject_ShouldReturnEmpty() { // Arrange var applicationId = Guid.NewGuid(); @@ -175,7 +191,7 @@ public async Task GetApplicationContactsBySubjectAsync_ShouldMatchCaseInsensitiv { new ApplicationFormSubmission { - OidcSub = "TESTUSER", + OidcSub = "OTHERUSER", ApplicationId = applicationId, ApplicantId = Guid.NewGuid(), ApplicationFormId = Guid.NewGuid() @@ -187,71 +203,60 @@ public async Task GetApplicationContactsBySubjectAsync_ShouldMatchCaseInsensitiv WithId(new ApplicationContact { ApplicationId = applicationId, - ContactFullName = "Case Test", - ContactType = "ADDITIONAL_CONTACT" + ContactFullName = "Should Not Match" }, Guid.NewGuid()) }.AsAsyncQueryable(); _submissionRepository.GetQueryableAsync().Returns(submissions); _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + _applicationRepository.GetQueryableAsync().Returns( + new[] { WithId(new Application(), applicationId) }.AsAsyncQueryable()); // Act - var result = await _service.GetApplicationContactsBySubjectAsync("testuser@IDIR"); + var result = await _service.GetApplicationContactsBySubjectAsync("DIFFERENTUSER"); // Assert - result.Count.ShouldBe(1); + result.ShouldBeEmpty(); } [Fact] - public async Task GetApplicationContactsBySubjectAsync_ShouldStripDomainFromSubject() + public async Task GetApplicationContactsBySubjectAsync_WithNoSubmissions_ShouldReturnEmpty() { // Arrange - var applicationId = Guid.NewGuid(); - - var submissions = new[] - { - new ApplicationFormSubmission - { - OidcSub = "MYUSER", - ApplicationId = applicationId, - ApplicantId = Guid.NewGuid(), - ApplicationFormId = Guid.NewGuid() - } - }.AsAsyncQueryable(); - - var applicationContacts = new[] - { - WithId(new ApplicationContact - { - ApplicationId = applicationId, - ContactFullName = "Domain Strip Test", - ContactType = "ADDITIONAL_CONTACT" - }, Guid.NewGuid()) - }.AsAsyncQueryable(); - - _submissionRepository.GetQueryableAsync().Returns(submissions); - _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + _submissionRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationContactRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); // Act - var result = await _service.GetApplicationContactsBySubjectAsync("myuser@differentdomain"); + var result = await _service.GetApplicationContactsBySubjectAsync("TESTUSER"); // Assert - result.Count.ShouldBe(1); - result[0].Name.ShouldBe("Domain Strip Test"); + result.ShouldBeEmpty(); } [Fact] - public async Task GetApplicationContactsBySubjectAsync_WithSubjectWithoutAtSign_ShouldStillMatch() + public async Task GetApplicationContactsBySubjectAsync_WithMultipleSubmissions_ShouldReturnAllContacts() { // Arrange - var applicationId = Guid.NewGuid(); + var appId1 = Guid.NewGuid(); + var appId2 = Guid.NewGuid(); var submissions = new[] { new ApplicationFormSubmission { - OidcSub = "PLAINUSER", - ApplicationId = applicationId, + OidcSub = "TESTUSER", + ApplicationId = appId1, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + }, + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = appId2, ApplicantId = Guid.NewGuid(), ApplicationFormId = Guid.NewGuid() } @@ -261,125 +266,112 @@ public async Task GetApplicationContactsBySubjectAsync_WithSubjectWithoutAtSign_ { WithId(new ApplicationContact { - ApplicationId = applicationId, - ContactFullName = "Plain User Contact", + ApplicationId = appId1, + ContactFullName = "Contact App 1", ContactType = "ADDITIONAL_CONTACT" + }, Guid.NewGuid()), + WithId(new ApplicationContact + { + ApplicationId = appId2, + ContactFullName = "Contact App 2", + ContactType = "ADDITIONAL_SIGNING_AUTHORITY" }, Guid.NewGuid()) }.AsAsyncQueryable(); _submissionRepository.GetQueryableAsync().Returns(submissions); _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + _applicationRepository.GetQueryableAsync().Returns( + new[] + { + WithId(new Application(), appId1), + WithId(new Application(), appId2) + }.AsAsyncQueryable()); // Act - var result = await _service.GetApplicationContactsBySubjectAsync("plainuser"); + var result = await _service.GetApplicationContactsBySubjectAsync("TESTUSER"); // Assert - result.Count.ShouldBe(1); + result.Count.ShouldBe(2); + result.ShouldAllBe(c => !c.IsEditable); + result.ShouldAllBe(c => !c.IsPrimary); } [Fact] - public async Task GetApplicationContactsBySubjectAsync_WithNonMatchingSubject_ShouldReturnEmpty() + public async Task GetApplicantAgentContactsBySubjectAsync_WithMatchingSubmission_ShouldReturnAgentContacts() { // Arrange var applicationId = Guid.NewGuid(); + var agentId = Guid.NewGuid(); var submissions = new[] { new ApplicationFormSubmission { - OidcSub = "OTHERUSER", + OidcSub = "TESTUSER", ApplicationId = applicationId, ApplicantId = Guid.NewGuid(), ApplicationFormId = Guid.NewGuid() } }.AsAsyncQueryable(); - var applicationContacts = new[] + var agents = new[] { - WithId(new ApplicationContact + WithId(new ApplicantAgent { ApplicationId = applicationId, - ContactFullName = "Should Not Match" - }, Guid.NewGuid()) + ApplicantId = Guid.NewGuid(), + Name = "Agent Smith", + Title = "Signing Authority", + Email = "agent@example.com", + Phone = "777-7777", + PhoneExtension = "201", + Phone2 = "888-8888", + RoleForApplicant = "Primary Contact" + }, agentId) }.AsAsyncQueryable(); _submissionRepository.GetQueryableAsync().Returns(submissions); - _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + _applicantAgentRepository.GetQueryableAsync().Returns(agents); + _applicationRepository.GetQueryableAsync().Returns( + new[] { WithId(new Application { ReferenceNo = "REF-AGENT-001" }, applicationId) }.AsAsyncQueryable()); // Act - var result = await _service.GetApplicationContactsBySubjectAsync("differentuser@idir"); + var result = await _service.GetApplicantAgentContactsBySubjectAsync("TESTUSER"); // Assert - result.ShouldBeEmpty(); + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(agentId); + contact.Name.ShouldBe("Agent Smith"); + contact.Title.ShouldBe("Signing Authority"); + contact.Email.ShouldBe("agent@example.com"); + contact.WorkPhoneNumber.ShouldBe("777-7777"); + contact.WorkPhoneExtension.ShouldBe("201"); + contact.MobilePhoneNumber.ShouldBe("888-8888"); + contact.Role.ShouldBe("Primary Contact"); + contact.ContactType.ShouldBe("ApplicantAgent"); + contact.IsPrimary.ShouldBeFalse(); + contact.IsEditable.ShouldBeFalse(); + contact.ApplicationId.ShouldBe(applicationId); + contact.ReferenceNo.ShouldBe("REF-AGENT-001"); } [Fact] - public async Task GetApplicationContactsBySubjectAsync_WithNoSubmissions_ShouldReturnEmpty() + public async Task GetApplicantAgentContactsBySubjectAsync_WithNoMatchingSubmissions_ShouldReturnEmpty() { // Arrange _submissionRepository.GetQueryableAsync() .Returns(Array.Empty().AsAsyncQueryable()); - _applicationContactRepository.GetQueryableAsync() - .Returns(Array.Empty().AsAsyncQueryable()); + _applicantAgentRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); // Act - var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + var result = await _service.GetApplicantAgentContactsBySubjectAsync("TESTUSER"); // Assert result.ShouldBeEmpty(); } - - [Fact] - public async Task GetApplicationContactsBySubjectAsync_WithMultipleSubmissions_ShouldReturnAllContacts() - { - // Arrange - var appId1 = Guid.NewGuid(); - var appId2 = Guid.NewGuid(); - - var submissions = new[] - { - new ApplicationFormSubmission - { - OidcSub = "TESTUSER", - ApplicationId = appId1, - ApplicantId = Guid.NewGuid(), - ApplicationFormId = Guid.NewGuid() - }, - new ApplicationFormSubmission - { - OidcSub = "TESTUSER", - ApplicationId = appId2, - ApplicantId = Guid.NewGuid(), - ApplicationFormId = Guid.NewGuid() - } - }.AsAsyncQueryable(); - - var applicationContacts = new[] - { - WithId(new ApplicationContact - { - ApplicationId = appId1, - ContactFullName = "Contact App 1", - ContactType = "ADDITIONAL_CONTACT" - }, Guid.NewGuid()), - WithId(new ApplicationContact - { - ApplicationId = appId2, - ContactFullName = "Contact App 2", - ContactType = "ADDITIONAL_SIGNING_AUTHORITY" - }, Guid.NewGuid()) - }.AsAsyncQueryable(); - - _submissionRepository.GetQueryableAsync().Returns(submissions); - _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); - - // Act - var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); - - // Assert - result.Count.ShouldBe(2); - result.ShouldAllBe(c => !c.IsEditable); - result.ShouldAllBe(c => !c.IsPrimary); - } } } diff --git a/documentation/applicant-portal/applicant-profile-data-providers.md b/documentation/applicant-portal/applicant-profile-data-providers.md index 93c919dbf..438f7a1bb 100644 --- a/documentation/applicant-portal/applicant-profile-data-providers.md +++ b/documentation/applicant-portal/applicant-profile-data-providers.md @@ -101,7 +101,7 @@ sequenceDiagram ### 1. ContactInfoDataProvider (`CONTACTINFO`) -**Purpose:** Aggregates contact information from two sources — profile-linked contacts and application-level contacts. +**Purpose:** Aggregates contact information from three sources — profile-linked contacts, application-level contacts, and applicant agent contacts derived from the submission login token. **Dependencies:** - `ICurrentTenant` — for multi-tenant scoping @@ -112,7 +112,8 @@ sequenceDiagram 1. Switches to the requested tenant context. 2. Retrieves **profile contacts** — contacts linked to the applicant profile via `ContactLink` records where `RelatedEntityType == "ApplicantProfile"` and `RelatedEntityId == profileId`. These are **editable** (`IsEditable = true`). 3. Retrieves **application contacts** — contacts on applications whose form submissions match the normalized OIDC subject. These are **read-only** (`IsEditable = false`). -4. Merges both lists into a single `ApplicantContactInfoDto.Contacts` collection. +4. Retrieves **applicant agent contacts** — contact information derived from `ApplicantAgent` records on applications whose form submissions match the normalized OIDC subject. The join path is `Submission → Application → ApplicantAgent`. These are **read-only** (`IsEditable = false`). +5. Merges all three lists into a single `ApplicantContactInfoDto.Contacts` collection. **Subject Normalization:** The OIDC subject (e.g. `user@idir`) is normalized by stripping everything after `@` and converting to uppercase. @@ -132,15 +133,27 @@ flowchart TD AC1["Normalize Subject
strip domain, uppercase"] AC2["Query ApplicationFormSubmission
WHERE OidcSub = normalizedSubject"] AC3["JOIN ApplicationContact
ON ApplicationId"] + AC3b["JOIN Application
ON ApplicationId
for ReferenceNo"] AC4["Map to ContactInfoItemDto
IsEditable = false"] - AC1 --> AC2 --> AC3 --> AC4 + AC1 --> AC2 --> AC3 --> AC3b --> AC4 + end + + subgraph AgentContacts["Applicant Agent Contacts - Read-Only"] + AG1["Normalize Subject
strip domain, uppercase"] + AG2["Query ApplicationFormSubmission
WHERE OidcSub = normalizedSubject"] + AG3["JOIN ApplicantAgent
ON ApplicationId"] + AG3b["JOIN Application
ON ApplicationId
for ReferenceNo"] + AG4["Map to ContactInfoItemDto
ContactType = 'ApplicantAgent'
IsEditable = false"] + AG1 --> AG2 --> AG3 --> AG3b --> AG4 end Start --> Tenant Tenant --> PC1 Tenant --> AC1 + Tenant --> AG1 PC3 --> Merge["Merge into Contacts list"] AC4 --> Merge + AG4 --> Merge Merge --> Return([Return ApplicantContactInfoDto]) ``` @@ -149,7 +162,26 @@ flowchart TD | Source | Entity | Join Path | Editable | |--------|--------|-----------|----------| | Profile Contacts | `ContactLink` → `Contact` | `ContactLink.RelatedEntityId = profileId` | ✅ Yes | -| Application Contacts | `ApplicationFormSubmission` → `ApplicationContact` | `Submission.OidcSub = normalizedSubject` | ❌ No | +| Application Contacts | `ApplicationFormSubmission` → `ApplicationContact` → `Application` | `Submission.OidcSub = normalizedSubject`, `Application.Id` for `ReferenceNo` | ❌ No | +| Applicant Agent Contacts | `ApplicationFormSubmission` → `ApplicantAgent` → `Application` | `Submission.ApplicationId = Agent.ApplicationId`, `Application.Id` for `ReferenceNo` | ❌ No | + +**Applicant Agent Field Mapping:** + +The `ApplicantAgent` entity is populated from the CHEFS submission login token during intake import. Its fields are mapped to `ContactInfoItemDto` as follows: + +| ApplicantAgent Field | ContactInfoItemDto Field | +|---------------------|-------------------------| +| `Id` | `ContactId` | +| `Name` | `Name` | +| `Title` | `Title` | +| `Email` | `Email` | +| `Phone` | `WorkPhoneNumber` | +| `PhoneExtension` | `WorkPhoneExtension` | +| `Phone2` | `MobilePhoneNumber` | +| `RoleForApplicant` | `Role` | +| `ApplicationId` | `ApplicationId` | +| `Application.ReferenceNo` | `ReferenceNo` | +| _(literal)_ `"ApplicantAgent"` | `ContactType` | --- @@ -356,7 +388,7 @@ Providers distinguish between **editable** and **read-only** data: | Provider | Editable Source | Read-Only Source | |----------|----------------|-----------------| -| ContactInfo | Profile-linked contacts | Application-level contacts | +| ContactInfo | Profile-linked contacts | Application-level contacts, Applicant agent contacts | | AddressInfo | Addresses linked via ApplicantId | Addresses linked via ApplicationId | ---