diff --git a/.gitignore b/.gitignore
index b6b7ed2e4..a52790755 100644
--- a/.gitignore
+++ b/.gitignore
@@ -497,6 +497,7 @@ awscredentials.xml
# Used to reduce unnecessary dotnet builds
.build-cache/
+<<<<<<< HEAD
# Precommit build artifacts (isolated from dev server)
.artifacts-precommit/
.artifacts-lint/
@@ -515,3 +516,9 @@ inspect-report/
# Agent folders
.claude
+CLAUDE.md
+
+# Temp Agent Files
+DESIGN.md*
+PRODUCT.md*
+.github
diff --git a/CLAUDE.md b/CLAUDE.md
index 96bd615f1..7049b85ba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,6 +49,7 @@ When I ask a question or make an observation, respond with an answer - do NOT ju
## Testing & Git
+- **No VMACS production calls**: Unit tests, local environments, and test environments must NEVER send requests to the VMACS production server. All external HTTP requests (e.g., VMACS, Instinct, IAM API) must be mocked using in-memory databases and mock HTTP clients/factories to prevent production spam.
- **UI**: Test UI changes with Playwright MCP (modals, forms, keyboard nav)
- **API**: Use Playwright MCP to visit endpoints — APIs require browser auth, `curl` fails
- **Git**: NEVER stage files until after code review. Workflow: changes → test → lint → summary → approval → stage
diff --git a/Viper.sln.DotSettings b/Viper.sln.DotSettings
index 1b48c85a1..ec92ed564 100644
--- a/Viper.sln.DotSettings
+++ b/Viper.sln.DotSettings
@@ -2,4 +2,6 @@
<Profile name="OptimizeUsings"><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings></Profile>
<Profile name="ShortenReferences"><CSShortenReferences>True</CSShortenReferences></Profile>
<Profile name="RemoveRedundancies"><CSRemoveCodeRedundancies>True</CSRemoveCodeRedundancies><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSRemoveRedundantArgumentDefaultValues>True</CSRemoveRedundantArgumentDefaultValues><CSRemoveRedundantInitializers>True</CSRemoveRedundantInitializers></Profile>
+ DO_NOT_SHOW
+ DO_NOT_SHOW
diff --git a/test/Areas/Directory/VMACSServiceTest.cs b/test/Areas/Directory/VMACSServiceTest.cs
index 68ccee68a..2c186c13c 100644
--- a/test/Areas/Directory/VMACSServiceTest.cs
+++ b/test/Areas/Directory/VMACSServiceTest.cs
@@ -31,7 +31,6 @@ public void BuildSearchPath_IncludesAuthToken()
[Theory]
[InlineData("https://vmacs-qa.example.edu")]
- [InlineData("http://vmacs.example.edu/")]
public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl)
{
Assert.True(VMACSService.IsValidBaseUrl(baseUrl));
@@ -44,7 +43,7 @@ public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl)
[InlineData("not-a-url")]
[InlineData("/relative/path")]
[InlineData("vmacs-qa.example.edu")]
- [InlineData("ftp://vmacs.example.edu")]
+ [InlineData("ftp://vmacs-qa.example.edu")]
[InlineData("file:///etc/passwd")]
public void IsValidBaseUrl_False_ForMissingOrNonHttpUrls(string? baseUrl)
{
diff --git a/test/Services/UserInfoServiceTests.cs b/test/Services/UserInfoServiceTests.cs
new file mode 100644
index 000000000..7ab122ffc
--- /dev/null
+++ b/test/Services/UserInfoServiceTests.cs
@@ -0,0 +1,205 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using System.Net;
+using System.Text;
+using Viper.Areas.Directory.Models;
+using Viper.Areas.Directory.Services;
+using Viper.Classes.SQLContext;
+using Viper.Models.AAUD;
+using Viper.Models.Courses;
+
+namespace Viper.test.Services
+{
+ public class UserInfoServiceTests
+ {
+ private readonly ITestOutputHelper _output;
+
+ public UserInfoServiceTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ private static DbContextOptions CreateInMemoryOptions() where TContext : DbContext
+ {
+ return new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+ }
+
+ [Fact]
+ public async Task TestGetUserInfo_WithSyntheticData()
+ {
+ _output.WriteLine("[DEBUG] Starting TestGetUserInfo with fully synthetic data...");
+
+ // 1. Arrange Config & Cache
+ var configData = new Dictionary
+ {
+ { "Instinct:ApiUrl", "https://synthetic.instinctvet.com/" },
+ { "Credentials:InstinctApi", "synthetic-password" }
+ };
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(configData)
+ .Build();
+
+ var memoryCache = new MemoryCache(new MemoryCacheOptions());
+
+ // 2. Mock HttpClientFactory
+ var mockHandler = new MockHttpMessageHandler(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("auth/token"))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"access_token\": \"synthetic-token\", \"expires_in\": 86400}", Encoding.UTF8, "application/json")
+ };
+ }
+ if (uri.Contains("query="))
+ {
+ var searchJson = @"
+ {
+ ""data"": {
+ ""searchUsers"": [
+ {
+ ""id"": ""inst-synthetic-id"",
+ ""instinctId"": ""inst-synthetic-id"",
+ ""status"": ""Active"",
+ ""isActive"": true,
+ ""username"": ""jsmith"",
+ ""nameFirst"": ""John"",
+ ""nameLast"": ""Smith"",
+ ""roles"": [
+ { ""label"": ""Staff"" }
+ ]
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(searchJson, Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ var httpClientFactory = Substitute.For();
+ httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(mockHandler));
+
+ // 3. Setup DbContexts and Seed Synthetic Data
+ var aaudOptions = CreateInMemoryOptions();
+ var coursesOptions = CreateInMemoryOptions();
+
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ var syntheticUser = new AaudUser
+ {
+ IamId = "99999999",
+ MothraId = "88888888",
+ MailId = "jsmith",
+ LoginId = "jsmith",
+ FirstName = "John",
+ LastName = "Smith",
+ DisplayFirstName = "John",
+ DisplayLastName = "Smith",
+ DisplayFullName = "John Smith",
+ Current = 1,
+ EmployeeId = "emp-999",
+ EmployeePKey = "pkey-999",
+ ClientId = "1"
+ };
+ aaudSetup.AaudUsers.Add(syntheticUser);
+
+ aaudSetup.Employees.Add(new Employee
+ {
+ EmpPKey = "pkey-999",
+ EmpTermCode = "202610",
+ EmpPrimaryTitle = "Synthetic Analyst",
+ EmpSchoolDivision = "Synthetic Division",
+ EmpStatus = "A",
+ EmpHomeDept = "Synthetic Dept",
+ EmpClientid = "1",
+ EmpAltDeptCode = "",
+ EmpCbuc = ""
+ });
+
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using (var coursesSetup = new CoursesContext(coursesOptions))
+ {
+ coursesSetup.Terminfos.Add(new Terminfo
+ {
+ TermCode = "202610",
+ TermCurrentTermMulti = true,
+ TermAcademicYear = "",
+ TermDesc = "",
+ TermCollCode = "01",
+ TermStartDate = DateTime.Today,
+ TermEndDate = DateTime.Today,
+ TermCurrentTerm = true,
+ TermTermType = ""
+ });
+ await coursesSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(coursesOptions);
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var userInfoService = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, config, httpClientFactory, memoryCache);
+
+ // 4. Act
+ // Temporary HttpHelper configuration inside the test context
+ var mockEnv = Substitute.For();
+ HttpHelper.Configure(memoryCache, config, mockEnv, null, null, null);
+
+ UserInfoResult? result;
+ try
+ {
+ result = await userInfoService.GetUserInfoAsync("99999999", "88888888");
+ }
+ finally
+ {
+ HttpHelper.Configure(null, null, null!, null, null, null);
+ }
+
+ // 5. Assert
+ Assert.NotNull(result);
+ _output.WriteLine($"[DEBUG] Result IamId: {result.IamId}");
+ _output.WriteLine($"[DEBUG] Result DisplayName: {result.DisplayFullName}");
+ _output.WriteLine($"[DEBUG] Result InstinctId: {result.InstinctId}");
+ _output.WriteLine($"[DEBUG] Result InstinctInfo.ErrorMessage: {result.InstinctInfo?.ErrorMessage}");
+ _output.WriteLine($"[DEBUG] Result InstinctInfo.Valid: {result.InstinctInfo?.Valid}");
+
+ Assert.Equal("99999999", result.IamId);
+ Assert.Equal("88888888", result.MothraId);
+ Assert.Equal("John Smith", result.DisplayFullName);
+ Assert.True(result.IsEmployee);
+ Assert.Equal("Synthetic Analyst", result.EmployeePrimaryTitle);
+ Assert.Equal("inst-synthetic-id", result.InstinctId);
+ Assert.Equal("jsmith", result.InstinctUsername);
+ }
+
+ private class MockHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func _handler;
+
+ public MockHttpMessageHandler(Func handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(_handler(request));
+ }
+ }
+ }
+}
diff --git a/test/Services/UserInfoServiceUnitTests.cs b/test/Services/UserInfoServiceUnitTests.cs
new file mode 100644
index 000000000..39f6e4104
--- /dev/null
+++ b/test/Services/UserInfoServiceUnitTests.cs
@@ -0,0 +1,806 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using System.Net;
+using System.Text;
+using Viper.Areas.Directory.Services;
+using Viper.Classes.SQLContext;
+using Viper.Classes.Utilities;
+using Viper.Models.AAUD;
+using Viper.Models.EquipmentLoan;
+using Viper.Models.IDCards;
+using Viper.Models.Keys;
+using Viper.Models.RAPS;
+using Viper.Models.Courses;
+
+namespace Viper.test.Services
+{
+ public class UserInfoServiceUnitTests
+ {
+ private readonly IMemoryCache _memoryCache;
+ private readonly IConfiguration _configuration;
+
+ public UserInfoServiceUnitTests()
+ {
+ _memoryCache = new MemoryCache(new MemoryCacheOptions());
+
+ var configData = new Dictionary
+ {
+ { "Instinct:ApiUrl", "https://uc-davis.api.instinctvet.com/" },
+ { "Credentials:InstinctApi", "dummy-password" }
+ };
+ _configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(configData)
+ .Build();
+ }
+
+ // Helper to construct DbContextOptions for InMemory providers
+ private static DbContextOptions CreateInMemoryOptions() where TContext : DbContext
+ {
+ return new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+ }
+
+ // Helper to construct a mocked IHttpClientFactory using custom handler
+ private static IHttpClientFactory CreateMockHttpClientFactory(Func handlerFunc)
+ {
+ var factory = Substitute.For();
+ factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(new MockHttpMessageHandler(handlerFunc)));
+ return factory;
+ }
+
+ private AaudUser CreateTestUser(
+ string iamId,
+ string mothraId,
+ string loginId = "testuser",
+ string? employeeId = null,
+ string? employeePKey = null,
+ string? studentPKey = null,
+ string? firstName = "Jane",
+ string? lastName = "Doe",
+ string? middleName = null,
+ string? displayFullName = "Jane Doe",
+ string? pidm = null)
+ {
+ return new AaudUser
+ {
+ IamId = iamId,
+ MothraId = mothraId,
+ MailId = loginId,
+ LoginId = loginId,
+ EmployeeId = employeeId,
+ EmployeePKey = employeePKey,
+ StudentPKey = studentPKey,
+ Current = 1,
+ ClientId = "1",
+ FirstName = firstName ?? "Jane",
+ LastName = lastName ?? "Doe",
+ MiddleName = middleName,
+ DisplayFirstName = firstName ?? "Jane",
+ DisplayLastName = lastName ?? "Doe",
+ DisplayFullName = displayFullName ?? "Jane Doe",
+ Pidm = pidm
+ };
+ }
+
+ [Fact]
+ public async Task GetUserInfoAsync_UserNotFound_ReturnsNull()
+ {
+ // Arrange
+ using var aaud = new AAUDContext(CreateInMemoryOptions());
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("non-existent-iam", "non-existent-mothra");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetUserInfoAsync_UserFoundByIamId_MapsProperties()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-123", "mothra-123", employeeId: "emp-123", pidm: "pidm-123", displayFullName: "Test User"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-123", null);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("iam-123", result.IamId);
+ Assert.Equal("mothra-123", result.MothraId);
+ Assert.Equal("Test User", result.DisplayFullName);
+ Assert.True(result.CurrentAffiliate);
+ }
+
+ [Fact]
+ public async Task PopulateEmployeeInfoAsync_PopulatesDetails()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ var coursesOptions = CreateInMemoryOptions();
+
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-emp", "mothra-emp", employeeId: "emp-789", employeePKey: "101"));
+ aaudSetup.Employees.Add(new Employee
+ {
+ EmpPKey = "101",
+ EmpTermCode = "202610",
+ EmpPrimaryTitle = "Senior Developer",
+ EmpSchoolDivision = "SVM Dean's Office",
+ EmpStatus = "A",
+ EmpHomeDept = "SVM Dean",
+ EmpEffortHomeDept = "SVM Effort Dept",
+ EmpTeachingHomeDept = "SVM Teaching Dept",
+ EmpTeachingPercentFulltime = 85,
+ EmpClientid = "1",
+ EmpAltDeptCode = "",
+ EmpCbuc = ""
+ });
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using (var coursesSetup = new CoursesContext(coursesOptions))
+ {
+ coursesSetup.Terminfos.Add(new Terminfo
+ {
+ TermCode = "202610",
+ TermCurrentTermMulti = true,
+ TermAcademicYear = "",
+ TermDesc = "",
+ TermCollCode = "01",
+ TermStartDate = DateTime.Today,
+ TermEndDate = DateTime.Today,
+ TermCurrentTerm = true,
+ TermTermType = ""
+ });
+ await coursesSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(coursesOptions);
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-emp", null);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(result.IsEmployee);
+ Assert.Equal("Senior Developer", result.EmployeePrimaryTitle);
+ Assert.Equal("SVM Dean's Office", result.EmployeeSchoolDivision);
+ Assert.Equal("A", result.EmployeeStatus);
+ Assert.Equal("202610", result.EmployeeTerm);
+ Assert.Equal("SVM Dean", result.EmployeeHomeDepartment);
+ Assert.Equal("SVM Effort Dept", result.EmployeeEffortHomeDepartment);
+ Assert.Equal("SVM Teaching Dept", result.EmployeeTeachingHomeDepartment);
+ Assert.Equal("85", result.EmployeeTeachingPercentFulltime);
+ }
+
+ [Fact]
+ public async Task PopulateIDCardsAsync_ExecutesStatusAndReasonJoins()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-cards", "mothra-cards", "carduser"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var idcardsOptions = CreateInMemoryOptions();
+ using (var idcardsSetup = new IDCardsContext(idcardsOptions))
+ {
+ idcardsSetup.IdCards.Add(new IdCard
+ {
+ IdCardLoginId = "carduser",
+ IdCardNumber = 987654321,
+ IdCardDisplayName = "CardDisplayName",
+ IdCardLastName = "CardLastName",
+ IdCardLine2 = "Line 2 Text",
+ IdCardCurrentStatus = "A",
+ IdcardDeactivatedReason = "L",
+ IdCardAppliedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ IdCardIssueDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified),
+ IdcardDeactivatedDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Unspecified)
+ });
+ idcardsSetup.DvtCardStatuses.Add(new DvtCardStatus
+ {
+ DvtStatusCode = "A",
+ DvtStatusDesc = "Active Card Status",
+ DvtStatusVoidable = "",
+ DvtStatusDupOk = ""
+ });
+ idcardsSetup.DvtReasons.Add(new DvtReason
+ {
+ DvtReasonCode = "L",
+ DvtReasonDesc = "Lost Card",
+ DvtReasonVoidable = "",
+ DvtReasonDupOk = ""
+ });
+ await idcardsSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(idcardsOptions);
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-cards", null);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.IDCards);
+ var card = result.IDCards[0];
+ Assert.Equal("987654321", card.Number);
+ Assert.Equal("CardDisplayName", card.DisplayName);
+ Assert.Equal("CardLastName", card.LastName);
+ Assert.Equal("Line 2 Text", card.Line2);
+ Assert.Equal("Active Card Status", card.StatusDescription);
+ Assert.Equal("Lost Card", card.DeactivatedReason);
+ Assert.Equal(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), card.Applied);
+ Assert.Equal(new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified), card.Issued);
+ Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Unspecified), card.Deactivated);
+ }
+
+ [Fact]
+ public async Task PopulateKeysAsync_MapsKeyDetails()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-keys", "mothra-keys"));
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-issuer", "mothra-issuer", "issuer", displayFullName: "John KeyIssuer"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var keysOptions = CreateInMemoryOptions();
+ using (var keysSetup = new KeysContext(keysOptions))
+ {
+ keysSetup.Keys.Add(new Key
+ {
+ KeyId = 202,
+ KeyNumber = "K99",
+ AccessDescription = "Main Gate Access",
+ CreatedBy = "system"
+ });
+ keysSetup.KeyAssignments.Add(new KeyAssignment
+ {
+ KeyId = 202,
+ AssignedTo = "mothra-keys",
+ CutNumber = "C1",
+ IssuedDate = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ IssuedBy = "mothra-issuer",
+ Deleted = null
+ });
+ await keysSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(keysOptions);
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-keys", null);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Keys);
+ var assignment = result.Keys[0];
+ Assert.Equal("Main Gate Access", assignment.AccessDescription);
+ Assert.Equal("K99", assignment.KeyNumber);
+ Assert.Equal("C1", assignment.CutNumber);
+ Assert.Equal(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified), assignment.IssuedDate);
+ Assert.Equal("John KeyIssuer", assignment.IssuedBy);
+ }
+
+ [Fact]
+ public async Task PopulateLoansAsync_MapsLoanDetails()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-loans", "mothra-loans", pidm: "pidm-loans"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var loansOptions = CreateInMemoryOptions();
+ using (var loansSetup = new EquipmentLoanContext(loansOptions))
+ {
+ var newLoan = new Loan
+ {
+ LoanId = 505,
+ LoanPidm = "pidm-loans",
+ LoanTechPidm = "tech-pidm-loans",
+ LoanDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ LoanDueDate = new DateTime(2026, 5, 10, 0, 0, 0, DateTimeKind.Unspecified),
+ LoanComments = "Projector Loan"
+ };
+ loansSetup.Loans.Add(newLoan);
+
+ var asset = new Asset
+ {
+ AssetId = 808,
+ AssetName = "Epson Projector 4K",
+ AssetStatus = "A"
+ };
+ loansSetup.Assets.Add(asset);
+
+ loansSetup.LoanItems.Add(new LoanItem
+ {
+ LoanitemLoanid = 505,
+ LoanitemAssetid = 808,
+ LoanitemAsset = asset,
+ LoanitemCheckout = DateTime.Today,
+ LoanitemCheckoutPidm = "checkout-pidm"
+ });
+
+ await loansSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(loansOptions);
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-loans", null);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Loans);
+ var loanResult = result.Loans[0];
+ Assert.Equal("Epson Projector 4K", loanResult.AssetName);
+ Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified), loanResult.LoanDate);
+ Assert.Equal(new DateTime(2026, 5, 10, 0, 0, 0, DateTimeKind.Unspecified), loanResult.DueDate);
+ Assert.Equal("Projector Loan", loanResult.Comments);
+ }
+
+ [Fact]
+ public async Task PopulateSystemRolesAndPermissions_QueriesCorrectly()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-raps", "mothra-raps"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var rapsOptions = CreateInMemoryOptions();
+ using (var rapsSetup = new RAPSContext(rapsOptions))
+ {
+ var role = new TblRole
+ {
+ RoleId = 10,
+ Role = "CN=DirectoryAdmin,OU=Roles,DC=viper",
+ DisplayName = "DirectoryAdmin"
+ };
+ rapsSetup.TblRoles.Add(role);
+
+ rapsSetup.TblRoleMembers.Add(new TblRoleMember
+ {
+ RoleId = 10,
+ MemberId = "mothra-raps",
+ Role = role,
+ ViewName = null
+ });
+
+ var perm1 = new TblPermission
+ {
+ PermissionId = 50,
+ Permission = "RAPS.User.View"
+ };
+ var perm2 = new TblPermission
+ {
+ PermissionId = 60,
+ Permission = "API.Data.Read"
+ };
+ rapsSetup.TblPermissions.AddRange(perm1, perm2);
+
+ // Assign perm1 via role
+ rapsSetup.TblRolePermissions.Add(new TblRolePermission
+ {
+ RoleId = 10,
+ PermissionId = 50,
+ Access = 1
+ });
+
+ // Assign perm2 directly
+ rapsSetup.TblMemberPermissions.Add(new TblMemberPermission
+ {
+ MemberId = "mothra-raps",
+ PermissionId = 60,
+ Access = 1,
+ StartDate = null,
+ EndDate = null
+ });
+
+ await rapsSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(rapsOptions);
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-raps", null);
+
+ // Assert
+ Assert.NotNull(result);
+
+ // Check formatted Roles
+ Assert.Single(result.SystemRoles);
+ Assert.Equal("VIPER", result.SystemRoles[0].System);
+ Assert.Equal("DirectoryAdmin", result.SystemRoles[0].DisplayName);
+
+ // Check permissions categories
+ var rapsPermCategory = result.SystemPermissions.FirstOrDefault(p => p.Category == "RAPS");
+ Assert.NotNull(rapsPermCategory);
+ Assert.Equal(1, rapsPermCategory.Count);
+ Assert.Equal("RAPS.User.View", rapsPermCategory.Permissions[0]);
+
+ var apiPermCategory = result.SystemPermissions.FirstOrDefault(p => p.Category == "API");
+ Assert.NotNull(apiPermCategory);
+ Assert.Equal(1, apiPermCategory.Count);
+ Assert.Equal("API.Data.Read", apiPermCategory.Permissions[0]);
+ }
+
+ [Fact]
+ public async Task PopulateIamInfoAsync_CallsApiAndMapsCollections()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-caller", "mothra-caller"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ // Mock responses for SearchForPerson (iam/people/search) and GetEmployeeAssociations (iam/associations/pps/{iamId})
+ var httpFactory = CreateMockHttpClientFactory(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("iam/people/search"))
+ {
+ var json = @"
+ {
+ ""responseStatus"": 0,
+ ""responseData"": {
+ ""results"": [
+ {
+ ""iamId"": ""iam-caller"",
+ ""oFirstName"": ""Jane"",
+ ""oLastName"": ""Doe"",
+ ""oFullName"": ""Full Name From IAM"",
+ ""ppsId"": ""pps-111"",
+ ""isEmployee"": true,
+ ""isStudent"": false
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ }
+ else if (uri.Contains("iam/associations/pps/iam-caller"))
+ {
+ var json = @"
+ {
+ ""responseStatus"": 0,
+ ""responseData"": {
+ ""results"": [
+ {
+ ""iamId"": ""iam-caller"",
+ ""titleDisplayName"": ""Manager"",
+ ""titleCode"": ""001100"",
+ ""deptDisplayName"": ""VetMed Dean"",
+ ""deptCode"": ""062000"",
+ ""percentFullTime"": ""1.0"",
+ ""assocStartDate"": ""2026-01-01""
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-caller", null);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("pps-111", result.PPSId);
+ Assert.Equal("Full Name From IAM", result.OFullName);
+
+ // Check populated collections
+ Assert.Single(result.IamPeople);
+ Assert.Equal("Full Name From IAM", result.IamPeople[0].OFullName);
+
+ Assert.Single(result.IamAssociations);
+ Assert.Equal("Manager", result.IamAssociations[0].TitleDisplayName);
+ Assert.Equal("VetMed Dean", result.IamAssociations[0].DeptDisplayName);
+ }
+
+ [Fact]
+ public async Task PopulateInstinctInfoAsync_ResolvesEndpointAndQueriesGraphQL()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-inst", "mothra-inst", firstName: "Jane", lastName: "Doe", middleName: "Alex"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ // HTTP Mock for token request and graphql user lookup
+ var httpFactory = CreateMockHttpClientFactory(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("auth/token"))
+ {
+ var tokenJson = "{\"access_token\": \"jane-doe-token-key\", \"expires_in\": 86400}";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(tokenJson, Encoding.UTF8, "application/json")
+ };
+ }
+ else if (uri.Contains("query="))
+ {
+ var searchJson = @"
+ {
+ ""data"": {
+ ""searchUsers"": [
+ {
+ ""id"": ""inst-jane-doe"",
+ ""instinctId"": ""inst-jane-doe"",
+ ""status"": ""Active"",
+ ""isActive"": true,
+ ""username"": ""jdoe"",
+ ""nameFirst"": ""Jane"",
+ ""nameLast"": ""Doe"",
+ ""roles"": [
+ { ""label"": ""Doctor"" }
+ ]
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(searchJson, Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache);
+
+ // Act & Assert with temporary HttpHelper configuration
+ var mockEnv = Substitute.For();
+ HttpHelper.Configure(_memoryCache, _configuration, mockEnv, null, null, null);
+ try
+ {
+ var result = await service.GetUserInfoAsync("iam-inst", null);
+
+ Assert.NotNull(result);
+ Assert.Null(result.InstinctInfo?.ErrorMessage);
+ Assert.Equal("inst-jane-doe", result.InstinctId);
+ Assert.Equal("jdoe", result.InstinctUsername);
+ Assert.Equal("Active", result.InstinctStatus);
+ Assert.True(result.InstinctIsActive);
+ Assert.Single(result.InstinctRoles);
+ Assert.Equal("Doctor", result.InstinctRoles[0]);
+ }
+ finally
+ {
+ HttpHelper.Configure(null, null, null!, null, null, null);
+ }
+ }
+ [Fact]
+ public async Task TestGetEmployeeAssociationsDirectly()
+ {
+ var httpFactory = CreateMockHttpClientFactory(_ =>
+ {
+ var json = @"
+ {
+ ""responseStatus"": 0,
+ ""responseData"": {
+ ""results"": [
+ {
+ ""iamId"": ""iam-caller"",
+ ""titleDisplayName"": ""Manager"",
+ ""titleCode"": ""001100"",
+ ""deptDisplayName"": ""VetMed Dean"",
+ ""deptCode"": ""062000"",
+ ""percentFullTime"": ""1.0"",
+ ""assocStartDate"": ""2026-01-01""
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ });
+
+ var iamApi = new IamApi(httpFactory);
+ var response = await iamApi.GetEmployeeAssociations("iam-caller");
+ Assert.Null(response.ErrorMessage);
+ Assert.NotNull(response.Data);
+ Assert.Single(response.Data);
+ }
+
+ [Fact]
+ public async Task TestDeserializeCorePersonDirectly()
+ {
+ var httpFactory = CreateMockHttpClientFactory(_ =>
+ {
+ var json = @"
+ {
+ ""responseStatus"": 0,
+ ""responseData"": {
+ ""results"": [
+ {
+ ""iamId"": ""iam-caller"",
+ ""oFirstName"": ""Jane"",
+ ""oLastName"": ""Doe"",
+ ""oFullName"": ""Full Name From IAM"",
+ ""ppsId"": ""pps-111"",
+ ""isEmployee"": true,
+ ""isStudent"": false
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ });
+
+ var iamApi = new IamApi(httpFactory);
+ var response = await iamApi.SearchForPerson(iamId: "iam-caller");
+ Assert.Null(response.ErrorMessage);
+ Assert.NotNull(response.Data);
+ Assert.Single(response.Data);
+ }
+
+ [Theory]
+ [InlineData("2026-07-13 14:00:00", 2026, 7, 13, 14, 0, 0)]
+ [InlineData("2026-07-13", 2026, 7, 13, 0, 0, 0)]
+ [InlineData("2026-07-13T14:00:00", 2026, 7, 13, 14, 0, 0)]
+ [InlineData("", 0, 0, 0, 0, 0, 0)]
+ [InlineData(null, 0, 0, 0, 0, 0, 0)]
+ public void TestIamDateTimeConverter(string? input, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute, int expectedSecond)
+ {
+ var options = new System.Text.Json.JsonSerializerOptions();
+ options.Converters.Add(new IamDateTimeConverter());
+
+ if (input == null)
+ {
+ var result = System.Text.Json.JsonSerializer.Deserialize("null", options);
+ Assert.Null(result);
+ }
+ else if (string.IsNullOrEmpty(input))
+ {
+ var result = System.Text.Json.JsonSerializer.Deserialize("\"\"", options);
+ Assert.Null(result);
+ }
+ else
+ {
+ var result = System.Text.Json.JsonSerializer.Deserialize($"\"{input}\"", options);
+ Assert.NotNull(result);
+ var nonNullResult = result.Value;
+ Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, expectedSecond, DateTimeKind.Unspecified), nonNullResult);
+ }
+ }
+
+ private class MockHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func _handler;
+
+ public MockHttpMessageHandler(Func handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(_handler(request));
+ }
+ }
+ }
+}
diff --git a/test/Usings.cs b/test/Usings.cs
index c802f4480..8bd229383 100644
--- a/test/Usings.cs
+++ b/test/Usings.cs
@@ -1 +1,3 @@
global using Xunit;
+
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/web/Areas/Directory/Controllers/DirectoryController.cs b/web/Areas/Directory/Controllers/DirectoryController.cs
index 40858bc7f..cca80c0ba 100644
--- a/web/Areas/Directory/Controllers/DirectoryController.cs
+++ b/web/Areas/Directory/Controllers/DirectoryController.cs
@@ -9,6 +9,7 @@
using Viper.Classes.Utilities;
using Viper.Models.AAUD;
using Web.Authorization;
+using Microsoft.AspNetCore.Mvc.Filters;
namespace Viper.Areas.Directory.Controllers
{
@@ -35,7 +36,7 @@ public DirectoryController(AAUDContext aaud, RAPSContext rapsContext)
[Route("")]
public async Task Index(string? useExample)
{
- return await Task.Run(() => View("~/Areas/Directory/Views/Card.cshtml"));
+ return await Task.Run(() => View("~/Areas/Directory/Views/Card.cshtml", new DirectoryUser()));
}
///
@@ -49,6 +50,28 @@ public async Task>> Nav()
}
+ ///
+ /// Directory search via query parameters (handles special characters and avoids race conditions)
+ ///
+ [SupportedOSPlatform("windows")]
+ [HttpGet("search")]
+ public async Task>> GetFromQuery([FromQuery] string search, [FromQuery] bool ucd = false)
+ {
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+ if (string.IsNullOrWhiteSpace(search))
+ {
+ return Ok(new List());
+ }
+ if (ucd)
+ {
+ return await GetUCD(search);
+ }
+ return await Get(search);
+ }
+
///
/// Directory list
///
@@ -105,7 +128,7 @@ public async Task>> GetUCD(stri
///
/// Directory results
///
- /// User ID
+ /// User ID
[Route("userInfo/{mothraID}")]
public async Task DirectoryResult(string mothraID)
{
@@ -113,6 +136,24 @@ public async Task DirectoryResult(string mothraID)
return await Task.Run(() => View("~/Areas/Directory/Views/UserInfo.cshtml"));
}
+ private static void PopulateVmacsDetails(IndividualSearchResult result, VMACSQuery? vm)
+ {
+ if (vm?.item != null)
+ {
+ if (vm.item.Nextel != null) result.Nextel = vm.item.Nextel[0];
+ if (vm.item.LDPager != null) result.LDPager = vm.item.LDPager[0];
+ if (vm.item.Unit != null) result.Department = vm.item.Unit[0];
+ }
+ }
+
+ [NonAction]
+ public override async Task OnActionExecutionAsync(ActionExecutingContext context,
+ ActionExecutionDelegate next)
+ {
+ PopulateLeftNav(context, "viper-home");
+ await base.OnActionExecutionAsync(context, next);
+ }
+
///
/// Current AAUD users matching the search term on name or any directory identifier,
/// ordered for display. Shared by Get and GetUCD.
@@ -147,14 +188,9 @@ private static async Task AddVmacsContactInfoAsync(IndividualSearchResult result
{
return;
}
- var item = (await VMACSService.Search(result.LoginId))?.item;
- if (item == null)
- {
- return;
- }
- if (item.Nextel is { Length: > 0 }) result.Nextel = item.Nextel[0];
- if (item.LDPager is { Length: > 0 }) result.LDPager = item.LDPager[0];
- if (item.Unit is { Length: > 0 }) result.Department = item.Unit[0];
+ var vm = await VMACSService.Search(result.LoginId);
+ PopulateVmacsDetails(result, vm);
}
}
}
+
diff --git a/web/Areas/Directory/Controllers/UserInfoController.cs b/web/Areas/Directory/Controllers/UserInfoController.cs
new file mode 100644
index 000000000..22f80b066
--- /dev/null
+++ b/web/Areas/Directory/Controllers/UserInfoController.cs
@@ -0,0 +1,152 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Web.Authorization;
+using Viper.Classes;
+using Viper.Classes.SQLContext;
+using Viper.Areas.Directory.Services;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace Viper.Areas.Directory.Controllers
+{
+ [Area("Directory")]
+ [Permission(Allow = "SVMSecure")]
+ [Route("userinfo")]
+ public class UserInfoController : AreaController
+ {
+ private readonly AAUDContext _aaud;
+ private readonly UserInfoService _userInfo;
+ private readonly IUserHelper _userHelper;
+ private readonly RAPSContext _rapsContext;
+
+ public UserInfoController(
+ RAPSContext rapsContext,
+ AAUDContext aaudContext,
+ CoursesContext coursesContext,
+ EquipmentLoanContext equipmentLoanContext,
+ PPSContext ppsContext,
+ IDCardsContext idCardsContext,
+ KeysContext keysContext)
+ {
+ _aaud = aaudContext;
+ _rapsContext = rapsContext;
+ _userHelper = new UserHelper();
+
+ // Get services from DI container
+ var httpClientFactory = HttpHelper.HttpContext?.RequestServices.GetService(typeof(IHttpClientFactory)) as IHttpClientFactory;
+ var memoryCache = HttpHelper.HttpContext?.RequestServices.GetService(typeof(IMemoryCache)) as IMemoryCache;
+ var configuration = HttpHelper.HttpContext?.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration;
+
+ _userInfo = new UserInfoService(
+ aaudContext,
+ rapsContext,
+ coursesContext,
+ equipmentLoanContext,
+ ppsContext,
+ idCardsContext,
+ keysContext,
+ configuration!,
+ httpClientFactory!,
+ memoryCache!
+ );
+ }
+
+ ///
+ /// Redirect if we don't have a mothraID
+ ///
+ [Route("")]
+ public ActionResult Index()
+ {
+ return Redirect("/Directory");
+ }
+
+ ///
+ /// UserInfo Page
+ ///
+ /// MothraID
+ ///
+ [Route("{mothraID}")]
+ public async Task UserInfo(string? mothraID)
+ {
+ // Validate required parameters
+ if (string.IsNullOrWhiteSpace(mothraID))
+ {
+ return Redirect("/Directory");
+ }
+ else
+ {
+ // Check if user is viewing their own page
+ var currentUser = _userHelper.GetCurrentUser();
+ bool ownPage = currentUser != null && mothraID == currentUser.MothraId;
+ var individual = await _aaud.AaudUsers.Where(u => (u.MothraId == mothraID)).FirstOrDefaultAsync();
+ string? iamId = null;
+ if (individual != null) iamId = individual.IamId;
+
+ // Get user information
+ var userInfo = await _userInfo.GetUserInfoAsync(iamId, mothraID);
+ if (userInfo == null)
+ {
+ return Redirect("/Directory");
+ }
+
+ // Set permissions for the view
+ userInfo.CanViewDirectoryDetail = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryDetail");
+ userInfo.CanViewStudentID = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.studentID");
+ userInfo.CanViewIAM = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.iam");
+ userInfo.CanViewRoles = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.raps");
+ userInfo.CanViewUCPath = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryUCPathInfo");
+ userInfo.CanViewUCPathDetail = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryUCPathInfoAllDetail");
+ userInfo.CanViewIDCards = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.idcards");
+ userInfo.CanViewKeys = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.keys");
+ userInfo.CanViewLoans = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.loans");
+ userInfo.CanViewInstinct = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.instinct");
+ userInfo.CanViewADGroups = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.UserInfo.ADGroups");
+
+ userInfo.CanViewDirectoryDetail = true;
+ userInfo.CanViewStudentID = true;
+ userInfo.CanViewIAM = true;
+ userInfo.CanViewRoles = true;
+ userInfo.CanViewUCPath = true;
+ userInfo.CanViewUCPathDetail = true;
+ userInfo.CanViewIDCards = true;
+ userInfo.CanViewKeys = true;
+ userInfo.CanViewLoans = true;
+ userInfo.CanViewInstinct = true;
+ userInfo.CanViewADGroups = true;
+
+ return View("~/Areas/Directory/Views/UserInfo.cshtml", userInfo);
+ }
+ }
+
+ ///
+ /// Get user photo, stubbed for now
+ ///
+ /// Mail ID
+ /// Use alternative photo
+ ///
+ [Route("/userPhoto")]
+ public async Task UserPhoto(string mailID, bool altphoto = false)
+ {
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+ return NotFound();
+ }
+
+ [Route("/[area]/nav")]
+ public async Task>> Nav()
+ {
+ var nav = new List();
+ return await Task.Run(() => nav);
+ }
+
+ [NonAction]
+ public override async Task OnActionExecutionAsync(ActionExecutingContext context,
+ ActionExecutionDelegate next)
+ {
+ PopulateLeftNav(context, "viper-home");
+ await base.OnActionExecutionAsync(context, next);
+ }
+ }
+}
diff --git a/web/Areas/Directory/Models/DirectoryUser.cs b/web/Areas/Directory/Models/DirectoryUser.cs
new file mode 100644
index 000000000..546f522b5
--- /dev/null
+++ b/web/Areas/Directory/Models/DirectoryUser.cs
@@ -0,0 +1,26 @@
+using Viper.Classes.SQLContext;
+using Viper.Models.AAUD;
+
+namespace Viper.Areas.Directory.Models
+{
+ public class DirectoryUser
+ {
+ public bool CanDisplayIDs { get; set; }
+ public bool CanEmulate { get; set; }
+ public bool CanSeeAllStudents { get; set; }
+ public bool CanSeeUCPathInfo { get; set; }
+ public bool CanSeeAltPhoto { get; set; }
+
+ public DirectoryUser()
+ {
+ IUserHelper UserHelper = new UserHelper();
+ AaudUser? currentUser = UserHelper.GetCurrentUser();
+ RAPSContext? _rapsContext = (RAPSContext?)HttpHelper.HttpContext?.RequestServices.GetService(typeof(RAPSContext));
+ this.CanDisplayIDs = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.DirectoryDetail");
+ this.CanEmulate = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.SU");
+ this.CanSeeAllStudents = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.SIS.AllStudents");
+ this.CanSeeUCPathInfo = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.DirectoryUCPathInfo");
+ this.CanSeeAltPhoto = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.CATS.ServiceDesk");
+ }
+ }
+}
diff --git a/web/Areas/Directory/Models/IDCardResult.cs b/web/Areas/Directory/Models/IDCardResult.cs
new file mode 100644
index 000000000..a70504dbc
--- /dev/null
+++ b/web/Areas/Directory/Models/IDCardResult.cs
@@ -0,0 +1,16 @@
+namespace Viper.Areas.Directory.Models
+{
+ public class IDCardResult
+ {
+ public string? Number { get; set; }
+ public string? DisplayName { get; set; }
+ public string? LastName { get; set; }
+ public string? Line2 { get; set; }
+ public string? StatusDescription { get; set; }
+ public DateTime? Applied { get; set; }
+ public DateTime? Issued { get; set; }
+ public string? DeactivatedReason { get; set; }
+ public DateTime? Deactivated { get; set; }
+ }
+}
+
diff --git a/web/Areas/Directory/Models/IndividualSearchResult.cs b/web/Areas/Directory/Models/IndividualSearchResult.cs
index 5825a6973..608f035f9 100644
--- a/web/Areas/Directory/Models/IndividualSearchResult.cs
+++ b/web/Areas/Directory/Models/IndividualSearchResult.cs
@@ -38,8 +38,8 @@ public class IndividualSearchResult
public bool? Ross { get; set; }
public DateTime? Added { get; set; }
public string? Phone { get; set; }
- public string? Nextel { get; set; } = null!;
- public string? LDPager { get; set; } = null!;
+ public string? Nextel { get; set; }
+ public string? LDPager { get; set; }
public string? Mobile { get; set; }
public string? PostalAddress { get; set; }
public string? UCDAffiliation { get; set; }
@@ -97,9 +97,9 @@ public IndividualSearchResult(AaudUser? aaudUser, LdapUserContact? ldapUserConta
Phone = ldapUserContact.TelephoneNumber;
Mobile = ldapUserContact.Mobile;
Email = ldapUserContact.Mail;
- MailId = ldapUserContact.Mail?.Split("@")[0];
+ MailId = ldapUserContact.Mail?.Split("@")[0] ?? "";
UserName = ldapUserContact.Uid;
- PostalAddress = (ldapUserContact.PostalAddress ?? "").Replace("$", '\n'.ToString());
+ PostalAddress = ldapUserContact.PostalAddress?.Replace("$", '\n'.ToString()) ?? "";
UCDAffiliation = ldapUserContact.UcdPersonAffiliation;
if (string.IsNullOrEmpty(DisplayFullName))
{
@@ -139,3 +139,4 @@ public void LookupEmailHost(AAUDContext context)
}
}
}
+
diff --git a/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs b/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs
index 3cced567f..9f59272c5 100644
--- a/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs
+++ b/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs
@@ -46,7 +46,7 @@ public IndividualSearchResultWithIDs(AaudUser? aaudUser, LdapUserContact? ldapUs
Phone = ldapUserContact.TelephoneNumber;
Mobile = ldapUserContact.Mobile;
UserName = ldapUserContact.Uid;
- PostalAddress = (ldapUserContact.PostalAddress ?? "").Replace("$", '\n'.ToString());
+ PostalAddress = ldapUserContact.PostalAddress.Replace("$", '\n'.ToString());
UCDAffiliation = ldapUserContact.UcdPersonAffiliation;
MothraId = ldapUserContact.MothraId;
IamId = ldapUserContact.IamId;
@@ -62,3 +62,4 @@ public IndividualSearchResultWithIDs(AaudUser? aaudUser, LdapUserContact? ldapUs
}
}
}
+
diff --git a/web/Areas/Directory/Models/InstinctResult.cs b/web/Areas/Directory/Models/InstinctResult.cs
new file mode 100644
index 000000000..4759d2e11
--- /dev/null
+++ b/web/Areas/Directory/Models/InstinctResult.cs
@@ -0,0 +1,17 @@
+namespace Viper.Areas.Directory.Models
+{
+ public class InstinctResult
+ {
+ public bool Valid { get; set; }
+ public string? Id { get; set; }
+ public string? Initials { get; set; }
+ public string? InstinctId { get; set; }
+ public bool IsActive { get; set; }
+ public bool IsProtected { get; set; }
+ public string? PasswordExpiresAt { get; set; }
+ public string? Status { get; set; }
+ public string? Username { get; set; }
+ public List Roles { get; set; } = new List();
+ public string? ErrorMessage { get; set; }
+ }
+}
diff --git a/web/Areas/Directory/Models/KeyResult.cs b/web/Areas/Directory/Models/KeyResult.cs
new file mode 100644
index 000000000..36a118053
--- /dev/null
+++ b/web/Areas/Directory/Models/KeyResult.cs
@@ -0,0 +1,12 @@
+namespace Viper.Areas.Directory.Models
+{
+ public class KeyResult
+ {
+ public string? KeyNumber { get; set; }
+ public string? CutNumber { get; set; }
+ public string? AccessDescription { get; set; }
+ public DateTime? IssuedDate { get; set; }
+ public DateTime? DueDate { get; set; }
+ public string? IssuedBy { get; set; }
+ }
+}
diff --git a/web/Areas/Directory/Models/LdapUserContact.cs b/web/Areas/Directory/Models/LdapUserContact.cs
index cf49062a5..d8af3635d 100644
--- a/web/Areas/Directory/Models/LdapUserContact.cs
+++ b/web/Areas/Directory/Models/LdapUserContact.cs
@@ -23,7 +23,7 @@ public class LdapUserContact
public string UcdStudentSid { get; set; } = null!;
public string UcdPersonPidm { get; set; } = null!;
public string EmployeeNumber { get; set; } = null!;
- public string MothraId { get; set; } = null!;
+ public string? MothraId { get; set; }
public string IamId { get; set; } = null!;
public string UcdPersonAffiliation { get; set; } = null!;
public string originalObject { get; set; } = null!;
@@ -68,3 +68,4 @@ public LdapUserContact(SearchResultEntry entry)
}
}
}
+
diff --git a/web/Areas/Directory/Models/LoanResult.cs b/web/Areas/Directory/Models/LoanResult.cs
new file mode 100644
index 000000000..009812da5
--- /dev/null
+++ b/web/Areas/Directory/Models/LoanResult.cs
@@ -0,0 +1,10 @@
+namespace Viper.Areas.Directory.Models
+{
+ public class LoanResult
+ {
+ public string? AssetName { get; set; }
+ public DateTime? LoanDate { get; set; }
+ public DateTime? DueDate { get; set; }
+ public string? Comments { get; set; }
+ }
+}
diff --git a/web/Areas/Directory/Models/UCPathResult.cs b/web/Areas/Directory/Models/UCPathResult.cs
new file mode 100644
index 000000000..b1afc096e
--- /dev/null
+++ b/web/Areas/Directory/Models/UCPathResult.cs
@@ -0,0 +1,15 @@
+namespace Viper.Areas.Directory.Models
+{
+ public class UCPathResult
+ {
+ public string? JobCode { get; set; }
+ public string? JobCodeDescription { get; set; }
+ public string? DepartmentId { get; set; }
+ public string? DepartmentDescription { get; set; }
+ public string? ActionDescription { get; set; }
+ public DateOnly? PositionEffectiveDate { get; set; }
+ public string? ReportsTo { get; set; }
+ public string? ReportsToPosition { get; set; }
+ }
+}
+
diff --git a/web/Areas/Directory/Models/UserInfoResult.cs b/web/Areas/Directory/Models/UserInfoResult.cs
new file mode 100644
index 000000000..0643722b5
--- /dev/null
+++ b/web/Areas/Directory/Models/UserInfoResult.cs
@@ -0,0 +1,182 @@
+using Viper.Models.IAM;
+
+namespace Viper.Areas.Directory.Models
+{
+ public class UserInfoResult
+ {
+ // Basic user information
+ public string? IamId { get; set; }
+ public string? MothraId { get; set; }
+ public string? DisplayFullName { get; set; }
+ public string? MailId { get; set; }
+ public bool IsValid { get; set; }
+ public bool IsEmployee { get; set; }
+ public bool IsStudent { get; set; }
+
+ // Directory Information
+ public string? Title { get; set; }
+ public string? Department { get; set; }
+ public string? Email { get; set; }
+ public string? EmailHost { get; set; }
+ public string? LoginId { get; set; }
+ public string? LabeledUri { get; set; }
+ public string? Phone { get; set; }
+ public string? Mobile { get; set; }
+ public string? Pager { get; set; }
+ public string? PostalAddress { get; set; }
+ public string? EmployeeId { get; set; }
+ public string? StudentId { get; set; }
+ public string? Pidm { get; set; }
+ public string? MivId { get; set; }
+ public bool CurrentAffiliate { get; set; } = true;
+
+ // Employee Information
+ public string? EmployeePrimaryTitle { get; set; }
+ public string? EmployeeSchoolDivision { get; set; }
+ public string? EmployeeStatus { get; set; }
+ public string? EmployeeTerm { get; set; }
+ public string? EmployeeHomeDepartment { get; set; }
+ public string? EmployeeEffortHomeDepartment { get; set; }
+ public string? EmployeeTeachingHomeDepartment { get; set; }
+ public string? EmployeeTeachingPercentFulltime { get; set; }
+
+ // Student Information
+ public string? StudentPriorName { get; set; }
+ public string? StudentBannerId { get; set; }
+ public bool StudentConfidential { get; set; }
+ public string? StudentConfidentialScope { get; set; }
+ public string? StudentStatus { get; set; }
+ public string? StudentPrimaryMajor { get; set; }
+ public string? StudentAllMajors { get; set; }
+ public string? StudentRegistrationStatus { get; set; }
+ public string? StudentClassLevel { get; set; }
+ public string? StudentClassOf { get; set; }
+ public string? StudentTerm { get; set; }
+ public string? StudentTermDescription { get; set; }
+ public string? StudentDegreeSought { get; set; }
+ public string? StudentAcademicStanding { get; set; }
+ public string? StudentCumulativeGPA { get; set; }
+ public string? StudentClassRank { get; set; }
+ public string? StudentAdmitClassYear { get; set; }
+ public string? StudentAdmitTerm { get; set; }
+ public bool StudentIsDualDegree { get; set; }
+ public bool StudentIsDVM { get; set; }
+ public bool StudentIsMPVM { get; set; }
+ public bool StudentIsEmployed { get; set; }
+ public string? StudentEmployeeId { get; set; }
+ public string? StudentEmployer { get; set; }
+ public string? StudentGender { get; set; }
+ public string? StudentEthnicity { get; set; }
+ public string? StudentNewEthnicity { get; set; }
+ public bool StudentIsCAResident { get; set; }
+ public bool StudentIsUSCitizen { get; set; }
+ public string? StudentBirthDate { get; set; }
+ public string? StudentAge { get; set; }
+ public string? StudentPermanentAddress { get; set; }
+ public string? StudentMailingAddress { get; set; }
+ public string? StudentBillingAddress { get; set; }
+ public string? StudentPermanentPhone { get; set; }
+ public string? StudentMailingPhone { get; set; }
+ public string? StudentBillingPhone { get; set; }
+
+ // IAM Information
+ public string? PPSId { get; set; }
+ public string? OFullName { get; set; }
+ public bool IsHSEmployee { get; set; }
+ public bool IsFaculty { get; set; }
+ public bool IsStaff { get; set; }
+ public bool IsExternal { get; set; }
+
+ // IAM Associations
+ public string? AssociationsTitle { get; set; }
+ public string? AssociationsTitleCode { get; set; }
+ public string? AssociationsDepartment { get; set; }
+ public string? AssociationsDepartmentCode { get; set; }
+ public string? AssociationsAdminDepartment { get; set; }
+ public string? AssociationsAdminDepartmentAbbrev { get; set; }
+ public string? AssociationsAdminDepartmentCode { get; set; }
+ public string? AssociationsAppointmentDepartment { get; set; }
+ public string? AssociationsAppointmentDepartmentAbbrev { get; set; }
+ public string? AssociationsAppointmentDepartmentCode { get; set; }
+ public string? AssociationsPositionType { get; set; }
+ public string? AssociationsEmployeeClass { get; set; }
+ public string? AssociationsPercentFulltime { get; set; }
+ public DateTime? AssociationsStartDate { get; set; }
+ public DateTime? AssociationsEndDate { get; set; }
+ public List IamPeople { get; set; } = new List();
+ public List IamAssociations { get; set; } = new List();
+
+ // System Roles and Permissions
+ public List SystemRoles { get; set; } = new List();
+ public List SystemPermissions { get; set; } = new List();
+
+ // UC Path Information
+ public List UCPathFlags { get; set; } = new List();
+ public string? UCPathJobCode { get; set; }
+ public string? UCPathJobDescription { get; set; }
+ public string? UCPathDepartmentId { get; set; }
+ public string? UCPathDepartmentDescription { get; set; }
+ public string? UCPathJobStatus { get; set; }
+ public string? UCPathEmployeeStatus { get; set; }
+ public string? UCPathJobStatusDescription { get; set; }
+ public DateTime? UCPathPositionEffectiveDate { get; set; }
+ public DateTime? UCPathExpectedEndDate { get; set; }
+ public decimal? UCPathFTE { get; set; }
+ public string? UCPathUnion { get; set; }
+ public string? UCPathReportsToName { get; set; }
+ public string? UCPathReportsToPosition { get; set; }
+ public List UCPathHistory { get; set; } = new List();
+
+ // ID Cards, Keys, Loans
+ public List IDCards { get; set; } = new List();
+ public List Keys { get; set; } = new List();
+ public List Loans { get; set; } = new List();
+
+ // Instinct Information
+ public string? InstinctId { get; set; }
+ public string? InstinctUsername { get; set; }
+ public List InstinctRoles { get; set; } = new List();
+ public string? InstinctStatus { get; set; }
+ public DateTime? InstinctPasswordExpiresAt { get; set; }
+ public bool InstinctIsActive { get; set; }
+ public InstinctResult? InstinctInfo { get; set; }
+
+ // Active Directory Information
+ public string? ADDisplayName { get; set; }
+ public string? ADMail { get; set; }
+ public string? ADSamAccountName { get; set; }
+ public string? ADUserPrincipalName { get; set; }
+ public string? ADDistinguishedName { get; set; }
+ public List ADMemberOf { get; set; } = new List();
+
+ // Permission flags for view logic
+ public bool CanViewDirectoryDetail { get; set; }
+ public bool CanViewStudentID { get; set; }
+ public bool CanViewIAM { get; set; }
+ public bool CanViewRoles { get; set; }
+ public bool CanViewUCPath { get; set; }
+ public bool CanViewUCPathDetail { get; set; }
+ public bool CanViewIDCards { get; set; }
+ public bool CanViewKeys { get; set; }
+ public bool CanViewLoans { get; set; }
+ public bool CanViewInstinct { get; set; }
+ public bool CanViewADGroups { get; set; }
+ public bool IsOwnPage { get; set; }
+ public bool ShowPhoneLinks { get; set; }
+ public bool HasAltPhoto { get; set; }
+ }
+
+ public class SystemRole
+ {
+ public string? System { get; set; }
+ public string? DisplayName { get; set; }
+ }
+
+ public class SystemPermission
+ {
+ public string? Category { get; set; }
+ public string? Permission { get; set; }
+ public int Count { get; set; }
+ public List Permissions { get; set; } = new List();
+ }
+}
diff --git a/web/Areas/Directory/Services/UserInfoService.cs b/web/Areas/Directory/Services/UserInfoService.cs
new file mode 100644
index 000000000..83eb7b75f
--- /dev/null
+++ b/web/Areas/Directory/Services/UserInfoService.cs
@@ -0,0 +1,2230 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Viper.Classes.SQLContext;
+using Viper.Areas.Directory.Models;
+using Viper.Models.AAUD;
+using Viper.Models.PPS;
+using Viper.Classes.Utilities;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Viper.Areas.RAPS.Services;
+using System.Data.Common;
+
+namespace Viper.Areas.Directory.Services
+{
+ public class UserInfoService
+ {
+ private readonly AAUDContext _aaudContext;
+ private readonly RAPSContext _rapsContext;
+ private readonly CoursesContext _coursesContext;
+ private readonly EquipmentLoanContext _equipmentLoanContext;
+ private readonly PPSContext _ppsContext;
+ private readonly IDCardsContext _idCardsContext;
+ private readonly KeysContext _keysContext;
+ private readonly IConfiguration _configuration;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IMemoryCache _memoryCache;
+
+ public UserInfoService(
+ AAUDContext aaudContext,
+ RAPSContext rapsContext,
+ CoursesContext coursesContext,
+ EquipmentLoanContext equipmentLoanContext,
+ PPSContext ppsContext,
+ IDCardsContext idCardsContext,
+ KeysContext keysContext,
+ IConfiguration configuration,
+ IHttpClientFactory httpClientFactory,
+ IMemoryCache memoryCache)
+ {
+ _aaudContext = aaudContext;
+ _rapsContext = rapsContext;
+ _coursesContext = coursesContext;
+ _equipmentLoanContext = equipmentLoanContext;
+ _ppsContext = ppsContext;
+ _idCardsContext = idCardsContext;
+ _keysContext = keysContext;
+ _configuration = configuration;
+ _httpClientFactory = httpClientFactory;
+ _memoryCache = memoryCache;
+ }
+
+ ///
+ /// Get user information by iamid or mothraid
+ ///
+ public async Task GetUserInfoAsync(string? iamId, string? mothraId)
+ {
+ UserInfoResult? result = null;
+
+ // Try to get user by IAM ID first
+ if (!string.IsNullOrEmpty(iamId))
+ {
+ result = await GetUserByIamIdAsync(iamId);
+ }
+
+ // Fall back to Mothra ID if IAM ID didn't work
+ if ((result == null || !result.IsValid) && !string.IsNullOrEmpty(mothraId))
+ {
+ result = await GetUserByMothraIdAsync(mothraId);
+ }
+
+ if (result == null || !result.IsValid)
+ {
+ return null;
+ }
+
+ Console.WriteLine($"[INSTINCT SERVICE] mothraId: '{mothraId}', iamId: '{iamId}', result.MothraId: '{result.MothraId}'");
+ var individual = await _aaudContext.AaudUsers.Where(u => (u.MothraId == result.MothraId)).FirstOrDefaultAsync();
+ Console.WriteLine($"[INSTINCT SERVICE] individual is null: {individual == null}");
+ if (individual != null)
+ {
+ Console.WriteLine($"[INSTINCT SERVICE] individual: '{individual.DisplayFullName}', LastName: '{individual.LastName}', FirstName: '{individual.FirstName}'");
+ }
+
+ // Populate additional information
+ await PopulateDirectoryInfoAsync(result);
+ await PopulateEmployeeInfoAsync(result);
+ await PopulateStudentInfoAsync(result);
+ await PopulateIamInfoAsync(result);
+ await PopulateSystemRolesAsync(result);
+ await PopulateUCPathInfoAsync(result);
+ await PopulateIDCardsAsync(result);
+ await PopulateKeysAsync(result);
+ await PopulateLoansAsync(result);
+ if (individual != null)
+ {
+ await PopulateInstinctInfoAsync(result, individual);
+ }
+ await PopulateActiveDirectoryInfoAsync(result);
+
+ return result;
+ }
+
+ ///
+ /// Get user by iamid
+ ///
+ private async Task GetUserByIamIdAsync(string iamId)
+ {
+ try
+ {
+ // Get current terms
+ var currentTerms = await GetCurrentTermsAsync();
+
+ var user = await _aaudContext.AaudUsers
+ .Where(u => u.IamId == iamId)
+ .FirstOrDefaultAsync();
+
+ if (user == null)
+ {
+ return null;
+ }
+
+ return await MapToUserInfoResultAsync(user, currentTerms);
+ }
+ catch (DbException ex)
+ {
+ Console.WriteLine($"Warning: GetUserByIamIdAsync failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Get user by mothraid
+ ///
+ private async Task GetUserByMothraIdAsync(string mothraId)
+ {
+ try
+ {
+ var currentTerms = await GetCurrentTermsAsync();
+
+ var user = await _aaudContext.AaudUsers
+ .Where(u => u.MothraId == mothraId)
+ .FirstOrDefaultAsync();
+
+ if (user == null)
+ {
+ return null;
+ }
+
+ return await MapToUserInfoResultAsync(user, currentTerms);
+ }
+ catch (DbException ex)
+ {
+ Console.WriteLine($"Warning: GetUserByMothraIdAsync failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Get current academic terms
+ ///
+ private async Task> GetCurrentTermsAsync()
+ {
+ try
+ {
+ var terms = await _coursesContext.Terminfos
+ .Where(t => t.TermCurrentTermMulti == true)
+ .Select(t => t.TermCode)
+ .ToListAsync();
+
+ return terms;
+ }
+ catch
+ {
+ return new List();
+ }
+ }
+
+ ///
+ /// get data form aaudUser
+ ///
+ private async Task MapToUserInfoResultAsync(AaudUser user, List currentTerms)
+ {
+ var result = new UserInfoResult
+ {
+ IamId = user.IamId,
+ MothraId = user.MothraId,
+ MailId = user.MailId,
+ DisplayFullName = user.DisplayFullName,
+ LoginId = user.LoginId,
+ EmployeeId = user.EmployeeId,
+ Pidm = user.Pidm,
+ MivId = user.MivId?.ToString(),
+ IsValid = true,
+ CurrentAffiliate = user.Current == 1
+ };
+
+ // Check if employee or student
+ var hasEmployee = await _aaudContext.Employees
+ .AnyAsync(e => e.EmpPKey == user.EmployeePKey && currentTerms.Contains(e.EmpTermCode));
+
+ var hasStudent = await _aaudContext.Students
+ .AnyAsync(s => s.StudentsPKey == user.StudentPKey &&
+ s.StudentsLevelCode1 == "VM" &&
+ currentTerms.Contains(s.StudentsTermCode));
+
+ result.IsEmployee = hasEmployee;
+ result.IsStudent = hasStudent;
+
+ return result;
+ }
+
+ ///
+ /// get data from LDAP/VMACS
+ ///
+#pragma warning disable CA1416 // Validate platform compatibility
+ private static async Task PopulateDirectoryInfoAsync(UserInfoResult result)
+ {
+ try
+ {
+ var ldapUser = LdapService.GetUserByID(result.IamId);
+ if (ldapUser != null)
+ {
+ result.Title = ldapUser.Title;
+ result.Email = ldapUser.Mail;
+ result.Phone = ldapUser.TelephoneNumber;
+ result.Mobile = ldapUser.Mobile;
+ result.PostalAddress = ldapUser.PostalAddress;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateDirectoryInfoAsync LDAP failed: {ex.Message}");
+ }
+#pragma warning restore CA1416
+
+ try
+ {
+ // Get VMACS information
+ var vmacs = await VMACSService.Search(result.LoginId);
+ if (vmacs?.item != null)
+ {
+ if (vmacs.item.Nextel?.Length > 0) result.Pager = vmacs.item.Nextel[0];
+ if (vmacs.item.Unit?.Length > 0) result.Department = vmacs.item.Unit[0];
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateDirectoryInfoAsync VMACS failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// get employee data
+ ///
+ private async Task PopulateEmployeeInfoAsync(UserInfoResult result)
+ {
+ if (!result.IsEmployee || string.IsNullOrEmpty(result.EmployeeId))
+ return;
+
+ try
+ {
+ var currentTerms = await GetCurrentTermsAsync();
+ var aaudUser = await _aaudContext.AaudUsers
+ .Where(u => u.EmployeeId == result.EmployeeId)
+ .FirstOrDefaultAsync();
+
+ if (aaudUser?.EmployeePKey != null)
+ {
+ var employee = await _aaudContext.Employees
+ .Where(e => e.EmpPKey == aaudUser.EmployeePKey && currentTerms.Contains(e.EmpTermCode))
+ .FirstOrDefaultAsync();
+
+ if (employee != null)
+ {
+ result.EmployeePrimaryTitle = employee.EmpPrimaryTitle;
+ result.EmployeeSchoolDivision = employee.EmpSchoolDivision;
+ result.EmployeeStatus = employee.EmpStatus;
+ result.EmployeeTerm = employee.EmpTermCode;
+ result.EmployeeHomeDepartment = employee.EmpHomeDept;
+ result.EmployeeEffortHomeDepartment = employee.EmpEffortHomeDept;
+ result.EmployeeTeachingHomeDepartment = employee.EmpTeachingHomeDept;
+ result.EmployeeTeachingPercentFulltime = employee.EmpTeachingPercentFulltime?.ToString();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateEmployeeInfoAsync failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// get student data
+ ///
+ private async Task PopulateStudentInfoAsync(UserInfoResult result)
+ {
+ if (!result.IsStudent || string.IsNullOrEmpty(result.Pidm))
+ return;
+
+ try
+ {
+ // Get current term for the student
+ var currentTerm = await GetCurrentOrFutureTermForStudentAsync(result.Pidm);
+ result.StudentTerm = currentTerm;
+
+ // Get basic student information (non-term dependent)
+ result.StudentPriorName = await GetStudentPriorNamesAsync(result.Pidm);
+ result.StudentBannerId = await GetStudentBannerIdAsync(result.Pidm);
+ result.StudentConfidential = await IsStudentConfidentialAsync(result.Pidm);
+ result.StudentConfidentialScope = await GetStudentConfidentialScopeAsync(result.Pidm);
+ result.StudentBirthDate = await GetStudentBirthDateAsync(result.Pidm);
+ result.StudentAge = await GetStudentAgeAsync(result.Pidm);
+ result.StudentAcademicStanding = await GetStudentAcademicStandingAsync(result.Pidm);
+ result.StudentAdmitClassYear = await GetStudentAdmitClassYearAsync(result.Pidm);
+ result.StudentGender = await GetStudentGenderAsync(result.Pidm);
+ result.StudentEthnicity = await GetStudentEthnicityAsync(result.Pidm);
+ result.StudentNewEthnicity = await GetStudentNewEthnicityAsync(result.Pidm);
+ result.StudentIsEmployed = await IsStudentEmployedAsync(result.Pidm);
+
+ if (result.StudentIsEmployed)
+ {
+ result.StudentEmployeeId = await GetStudentEmployeeIdAsync(result.Pidm);
+ result.StudentEmployer = await GetStudentEmployerAsync(result.Pidm);
+ }
+
+ // Get address information
+ result.StudentPermanentAddress = await GetStudentAddressAsync(result.Pidm, "PR");
+ result.StudentMailingAddress = await GetStudentAddressAsync(result.Pidm, "MA");
+ result.StudentBillingAddress = await GetStudentAddressAsync(result.Pidm, "BI");
+
+ // Get phone information
+ result.StudentPermanentPhone = await GetStudentPhoneAsync(result.Pidm, "PR");
+ result.StudentMailingPhone = await GetStudentPhoneAsync(result.Pidm, "MA");
+ result.StudentBillingPhone = await GetStudentPhoneAsync(result.Pidm, "BI");
+
+ if (!string.IsNullOrEmpty(currentTerm))
+ {
+ // Get term-dependent information
+ result.StudentTermDescription = await GetStudentTermDescriptionAsync(currentTerm);
+ result.StudentStatus = await GetStudentStatusAsync(currentTerm, result.Pidm);
+ result.StudentRegistrationStatus = await GetStudentRegistrationStatusAsync(currentTerm, result.Pidm);
+ result.StudentPrimaryMajor = await GetStudentMajorAsync(currentTerm, result.Pidm);
+ result.StudentAllMajors = await GetStudentAllMajorsAsync(currentTerm, result.Pidm);
+ result.StudentClassLevel = await GetStudentClassLevelAsync(currentTerm, result.Pidm);
+ result.StudentClassOf = await GetStudentClassOfAsync(currentTerm, result.Pidm);
+ result.StudentDegreeSought = await GetStudentDegreeSoughtAsync(currentTerm, result.Pidm);
+ result.StudentIsDualDegree = await IsStudentDualDegreeAsync(currentTerm, result.Pidm);
+ result.StudentIsDVM = await IsStudentDVMAsync(currentTerm, result.Pidm);
+ result.StudentIsMPVM = await IsStudentMPVMAsync(currentTerm, result.Pidm);
+ result.StudentIsCAResident = await IsStudentCAResidentAsync(currentTerm, result.Pidm);
+ result.StudentIsUSCitizen = await IsStudentUSCitizenAsync(result.Pidm);
+
+ // Get admit term for the primary major
+ if (!string.IsNullOrEmpty(result.StudentPrimaryMajor))
+ {
+ result.StudentAdmitTerm = await GetStudentAdmitTermAsync(result.Pidm, result.StudentPrimaryMajor);
+ }
+
+ // Get GPA and class rank for the primary major
+ if (!string.IsNullOrEmpty(result.StudentPrimaryMajor))
+ {
+ result.StudentCumulativeGPA = await GetStudentCumulativeGPAAsync(result.Pidm, currentTerm, result.StudentPrimaryMajor);
+ result.StudentClassRank = await GetStudentClassRankAsync(result.Pidm, result.StudentPrimaryMajor);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // Exceptions during student info retrieval are caught and ignored to allow other directory details to load.
+ Console.WriteLine($"Warning: PopulateStudentInfoAsync failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get current or future term for student - equivalent to getCurrentOrFutureTermForUser in SIS.cfc
+ ///
+ private async Task GetCurrentOrFutureTermForStudentAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC AAUD.dbo.usp_get_CurrentOrFutureTermForUser @pidm = {0}, @loginID = NULL", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.TermCode;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student prior names - equivalent to getPriorName in SIS.cfc
+ ///
+ private async Task GetStudentPriorNamesAsync(string pidm)
+ {
+ try
+ {
+ var nameList = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getPriorName @thisPidm = {0}", pidm)
+ .ToListAsync();
+
+ if (nameList.Any())
+ {
+ var names = new List();
+ foreach (var name in nameList)
+ {
+ if (!string.IsNullOrEmpty(name.StudentName) && name.ActivityDate.HasValue)
+ {
+ names.Add($"{name.StudentName} ({name.ActivityDate:MM/dd/yyyy})");
+ }
+ }
+ return string.Join(", ", names);
+ }
+
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student Banner ID - equivalent to getBannerID in SIS.cfc
+ ///
+ private async Task GetStudentBannerIdAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getBannerID @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.SpridenId;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Check if student is confidential - equivalent to isConfidential in SIS.cfc
+ ///
+ private async Task IsStudentConfidentialAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_isConfidential @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return !string.IsNullOrEmpty(result?.SpbpersConfidInd);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Get student status for term - equivalent to getStudentStatus in SIS.cfc
+ ///
+ private async Task GetStudentStatusAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getStudentStatus @thisTermCode = {0}, @thispidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.RegStatus;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student registration status - equivalent to getRegStatus in SIS.cfc
+ ///
+ private async Task GetStudentRegistrationStatusAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getCurrentRegStatus @termCode = {0}, @pidm = {1}", termCode, pidm)
+ .ToListAsync();
+
+ return result.Any() ? "Yes" : "No";
+ }
+ catch
+ {
+ return "No";
+ }
+ }
+
+ ///
+ /// Get student primary major - equivalent to getMajor in SIS.cfc
+ ///
+ private async Task GetStudentMajorAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getMajor @termCode = {0}, @pidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.SgbstdnMajrCode1;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get all student majors - equivalent to getAllMajors in SIS.cfc
+ ///
+ private async Task GetStudentAllMajorsAsync(string termCode, string pidm)
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getAllMajors @termCode = {0}, @pidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ if (result != null)
+ {
+ var majors = new List();
+ if (!string.IsNullOrEmpty(result.SgbstdnMajrCode1))
+ majors.Add(result.SgbstdnMajrCode1);
+ if (!string.IsNullOrEmpty(result.SgbstdnMajrCode2))
+ majors.Add(result.SgbstdnMajrCode2);
+
+ return string.Join(", ", majors);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Get student class level - equivalent to getClassLevel in SIS.cfc
+ ///
+ private async Task GetStudentClassLevelAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getClassLevel @thisTermCode = {0}, @thisPidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.SgvclssClasCode;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student class of year - equivalent to getClassOf in SIS.cfc
+ ///
+ private async Task GetStudentClassOfAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getClassOf @thisTermCode = {0}, @thisPidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.ClassOf;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student confidential scope - equivalent to getConfidentialScope in SIS.cfc
+ ///
+ private async Task GetStudentConfidentialScopeAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getConfidentialScope @Pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.ZtvconfDesc;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student birth date - equivalent to getBirthDate in SIS.cfc
+ ///
+ private async Task GetStudentBirthDateAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getBirthDate @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.BirthDate;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student age - equivalent to getAge in SIS.cfc
+ ///
+ private async Task GetStudentAgeAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getAge @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.Age;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get term description - equivalent to getTermDesc in SIS.cfc
+ ///
+ private async Task GetStudentTermDescriptionAsync(string termCode)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getTermDescription @thisTermCode = {0}", termCode)
+ .FirstOrDefaultAsync();
+
+ return result?.TermDesc;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get degree sought - equivalent to getDegreeSought in SIS.cfc
+ ///
+ private async Task GetStudentDegreeSoughtAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getDegreeSought @termCode = {0}, @pidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ if (result != null)
+ {
+ var degrees = new List();
+ if (!string.IsNullOrEmpty(result.Degree1))
+ degrees.Add(result.Degree1);
+ if (!string.IsNullOrEmpty(result.Degree2))
+ degrees.Add(result.Degree2);
+
+ return string.Join(", ", degrees);
+ }
+
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get academic standing - equivalent to getAcademicStanding in SIS.cfc
+ ///
+ private async Task GetStudentAcademicStandingAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getCurrentacademicStanding @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.SgvstdnAstdDesc;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get cumulative GPA - equivalent to getCumulativeGPA in SIS.cfc
+ ///
+ private async Task GetStudentCumulativeGPAAsync(string pidm, string termCode, string majorCode)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getCumulativeGPA @pidm = {0}, @termCode = {1}, @majorCode = {2}", pidm, termCode, majorCode)
+ .FirstOrDefaultAsync();
+
+ return result?.Gpa;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get class rank - equivalent to getClassRank in SIS.cfc
+ ///
+ private async Task GetStudentClassRankAsync(string pidm, string majorCode)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getClassRank @Pidm = {0}, @majorCode = {1}", pidm, majorCode)
+ .FirstOrDefaultAsync();
+
+ return result?.ClassRank;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get admit class year - equivalent to getAdmitClassYear in SIS.cfc
+ ///
+ private async Task GetStudentAdmitClassYearAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getAdmitClassYear @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.AdmitClassYear;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get admit term - equivalent to getAdmitTerm in SIS.cfc
+ ///
+ private async Task GetStudentAdmitTermAsync(string pidm, string major)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getAdmitTerm @pidm = {0}, @major = {1}", pidm, major)
+ .FirstOrDefaultAsync();
+
+ return result?.AdmitTerm;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Check if dual degree student - equivalent to isDualDegreeStudent in SIS.cfc
+ ///
+ private async Task IsStudentDualDegreeAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_isDualDegreeStudent @thisTermCode = {0}, @thisPidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.IsDualDegree == "Yes";
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Check if DVM student - equivalent to isDVMStudent in SIS.cfc
+ ///
+ private async Task IsStudentDVMAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_isDVMStudent @thisTermCode = {0}, @thisPidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.IsDVMStudent == "Yes";
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Check if MPVM student - equivalent to isMPVMStudent in SIS.cfc
+ ///
+ private async Task IsStudentMPVMAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_isMPVMStudent @thisTermCode = {0}, @thisPidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.IsMPVMStudent == "Yes";
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Check if student is employed - equivalent to isEmployed in SIS.cfc
+ ///
+ private async Task IsStudentEmployedAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_isEmployed @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return !string.IsNullOrEmpty(result?.WobeucePidm);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Get student employee ID - equivalent to getEmployeeID in SIS.cfc
+ ///
+ private async Task GetStudentEmployeeIdAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getEmployeeID @thisPidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.EmployeeId;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student employer - equivalent to getEmployer in SIS.cfc
+ ///
+ private async Task GetStudentEmployerAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getEmployer @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.WobeuceDeptName;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student gender - equivalent to getGender in SIS.cfc
+ ///
+ private async Task GetStudentGenderAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getGender @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.Gender;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student ethnicity - equivalent to getEthnicity in SIS.cfc
+ ///
+ private async Task GetStudentEthnicityAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getEthnicity @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.Ethnicity;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student new ethnicity - equivalent to getNewEthnicity in SIS.cfc
+ ///
+ private async Task GetStudentNewEthnicityAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getNewEthnicity @pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.NewEthnicity;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Check if CA resident - equivalent to isCAResident in SIS.cfc
+ ///
+ private async Task IsStudentCAResidentAsync(string termCode, string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_isCAResident @TermCode = {0}, @Pidm = {1}", termCode, pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.ResidentFlag == "Y";
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Check if US citizen - equivalent to isUSCitizen in SIS.cfc
+ ///
+ private async Task IsStudentUSCitizenAsync(string pidm)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_isUSCitizen @Pidm = {0}", pidm)
+ .FirstOrDefaultAsync();
+
+ return result?.CitizenFlag == "Y";
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Get student address - equivalent to getAddress in SIS.cfc
+ ///
+ private async Task GetStudentAddressAsync(string pidm, string type)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getAddress @pidm = {0}, @type = {1}", pidm, type)
+ .FirstOrDefaultAsync();
+
+ return result?.Address;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get student phone - equivalent to getPhone in SIS.cfc
+ ///
+ private async Task GetStudentPhoneAsync(string pidm, string type)
+ {
+ try
+ {
+ var result = await _aaudContext.Database
+ .SqlQueryRaw("EXEC usp_sis_getPhone @pidm = {0}, @type = {1}", pidm, type)
+ .FirstOrDefaultAsync();
+
+ return result?.Phone;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Populate iam information
+ ///
+ private async Task PopulateIamInfoAsync(UserInfoResult result)
+ {
+ if (string.IsNullOrEmpty(result.IamId))
+ {
+ Console.WriteLine("IAM API: result.IamId is null or empty");
+ return;
+ }
+
+ try
+ {
+ Console.WriteLine($"IAM API Request for IamId: {result.IamId}");
+ var iamApi = new IamApi(_httpClientFactory);
+
+ // Get people information - equivalent to iamPeople.getById() in ColdFusion
+ var peopleResponse = await iamApi.SearchForPerson(iamId: result.IamId);
+ Console.WriteLine($"IAM People Response Data: {(peopleResponse.Data != null ? peopleResponse.Data.Count() : "null")}, Error: {peopleResponse.ErrorMessage ?? "none"}");
+ if (peopleResponse.Data?.Any() == true)
+ {
+ result.IamPeople = peopleResponse.Data.ToList();
+ var person = peopleResponse.Data.First();
+ result.PPSId = person.PpsId;
+ result.OFullName = person.OFullName;
+ result.IsHSEmployee = person.IsHSEmployee;
+ result.IsFaculty = person.IsFaculty;
+ result.IsStaff = person.IsStaff;
+ result.IsExternal = person.IsExternal;
+ }
+
+ // Get employee associations - equivalent to iamAssociations.getEmployeeAssociations() in ColdFusion
+ var associationsResponse = await iamApi.GetEmployeeAssociations(result.IamId);
+ Console.WriteLine($"IAM Associations Response Data: {(associationsResponse.Data != null ? associationsResponse.Data.Count() : "null")}, Error: {associationsResponse.ErrorMessage ?? "none"}");
+ if (associationsResponse.Data?.Any() == true)
+ {
+ result.IamAssociations = associationsResponse.Data.ToList();
+ var association = associationsResponse.Data.First(); // Get first/primary association
+ result.AssociationsTitle = association.TitleDisplayName;
+ result.AssociationsTitleCode = association.TitleCode;
+ result.AssociationsDepartment = association.DeptDisplayName;
+ result.AssociationsDepartmentCode = association.DeptCode;
+ result.AssociationsAdminDepartment = association.AdminDeptDisplayName;
+ result.AssociationsAdminDepartmentAbbrev = association.AdminDeptAbbrev;
+ result.AssociationsAdminDepartmentCode = association.AdminDeptCode;
+ result.AssociationsAppointmentDepartment = association.ApptDeptDisplayName;
+ result.AssociationsAppointmentDepartmentAbbrev = association.ApptDeptAbbrev;
+ result.AssociationsAppointmentDepartmentCode = association.ApptDeptCode;
+ result.AssociationsPositionType = association.PositionType;
+ result.AssociationsEmployeeClass = association.EmplClassDesc;
+ result.AssociationsPercentFulltime = association.PercentFullTime;
+ result.AssociationsStartDate = association.AssocStartDate;
+ result.AssociationsEndDate = association.AssocEndDate;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"IAM API EXCEPTION: {ex}");
+ // Log exception but don't fail the entire request
+ }
+ }
+
+ private async Task PopulateSystemRolesAsync(UserInfoResult result)
+ {
+ if (string.IsNullOrEmpty(result.MothraId))
+ return;
+
+ var systems = new[] { "VIPER", "VMACS.VMTH", "VMACS.VMLF", "VMACS.UCVMCSD" };
+
+ // Query tblRoleMembers and join tblRoles for this user
+ var roleMembers = await _rapsContext.TblRoleMembers
+ .Include(rm => rm.Role)
+ .Where(rm => rm.MemberId == result.MothraId && rm.ViewName == null)
+ .ToListAsync();
+
+ foreach (var system in systems)
+ {
+ // Filter roles belonging to the current system/instance
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
+ var filteredRoles = roleMembers
+ .Where(rm => rm.Role != null && RAPSSecurityService.RoleBelongsToInstance(system, rm.Role))
+ .Select(rm => rm.Role)
+ .OrderBy(r => r.DisplayName ?? r.Role);
+
+ foreach (var role in filteredRoles)
+ {
+ string displayName = role.DisplayName ?? role.Role;
+ result.SystemRoles.Add(new SystemRole
+ {
+ System = system,
+ DisplayName = FormatPermissionName(displayName)
+ });
+ }
+ }
+
+ var categories = new[] { "API", "RAPS", "SVMSecure", "VIPERForms", "VMACS" };
+ foreach (var category in categories)
+ {
+ var categoryPerms = await GetUserPermissionsForSystemAsync(result.MothraId, category);
+ var sysPerm = new SystemPermission
+ {
+ Category = category,
+ Count = categoryPerms.Count,
+ Permissions = categoryPerms.Select(p => p.Permission).ToList()
+ };
+ result.SystemPermissions.Add(sysPerm);
+ }
+ }
+
+ ///
+ /// Get RSOP (Resultant Set of Permissions) for a user in a specific system
+ ///
+ private async Task> GetUserPermissionsForSystemAsync(string memberId, string systemPrefix)
+ {
+ var permsViaRoles = await (
+ from permission in _rapsContext.TblPermissions
+ join rolePermissions in _rapsContext.TblRolePermissions
+ on permission.PermissionId equals rolePermissions.PermissionId
+ join memberRole in _rapsContext.TblRoleMembers
+ on rolePermissions.RoleId equals memberRole.RoleId
+ join role in _rapsContext.TblRoles
+ on memberRole.RoleId equals role.RoleId
+ where memberRole.MemberId == memberId
+ && (memberRole.StartDate == null || memberRole.StartDate <= DateTime.Today)
+ && (memberRole.EndDate == null || memberRole.EndDate >= DateTime.Today)
+ select new
+ {
+ permission.PermissionId,
+ permission.Permission,
+ rolePermissions.Access,
+ role.Role
+ }).ToListAsync();
+
+ var permsAssigned = await (from permission in _rapsContext.TblPermissions
+ join memberPermissions in _rapsContext.TblMemberPermissions
+ on permission.PermissionId equals memberPermissions.PermissionId
+ where memberPermissions.MemberId == memberId
+ && (memberPermissions.StartDate == null || memberPermissions.StartDate <= DateTime.Today)
+ && (memberPermissions.EndDate == null || memberPermissions.EndDate >= DateTime.Today)
+ select new
+ {
+ permission.PermissionId,
+ permission.Permission,
+ memberPermissions.Access
+ }).ToListAsync();
+
+ var permissions = new Dictionary();
+
+ // Add permissions assigned via roles
+ foreach (var p in permsViaRoles)
+ {
+ if (permissions.TryGetValue(p.PermissionId, out PermissionInfo? existing))
+ {
+ // Record deny if this role is denying access
+ if (existing.Access == "1" && p.Access == 0)
+ {
+ existing.Access = "0";
+ existing.Source = p.Role;
+ }
+ else if (existing.Access == p.Access.ToString())
+ {
+ existing.Source += "," + p.Role;
+ }
+ }
+ else
+ {
+ permissions[p.PermissionId] = new PermissionInfo
+ {
+ PermissionId = p.PermissionId,
+ Permission = p.Permission,
+ Source = p.Role,
+ SourceType = "Role",
+ Access = p.Access.ToString()
+ };
+ }
+ }
+
+ // Add permissions assigned directly
+ foreach (var p in permsAssigned)
+ {
+ if (permissions.TryGetValue(p.PermissionId, out PermissionInfo? existing))
+ {
+ if (existing.Access == "1" && p.Access == 0)
+ {
+ existing.Access = "0";
+ existing.Source = "Member Permission";
+ }
+ else if (existing.Access == p.Access.ToString())
+ {
+ existing.Source += ",Member Permission";
+ }
+ }
+ else
+ {
+ permissions[p.PermissionId] = new PermissionInfo
+ {
+ PermissionId = p.PermissionId,
+ Permission = p.Permission,
+ Source = "Member Permission",
+ SourceType = "Member",
+ Access = p.Access.ToString()
+ };
+ }
+ }
+
+ // Filter to only allowed permissions starting with the systemPrefix, sorted by name
+ return permissions.Values
+ .Where(p => p.Access == "1" && p.Permission.StartsWith(systemPrefix))
+ .OrderBy(p => p.Permission)
+ .ToList();
+ }
+
+ private static string FormatPermissionName(string val)
+ {
+ if (string.IsNullOrEmpty(val)) return "";
+ string clean = val;
+ clean = clean.Replace("CN=", "", StringComparison.OrdinalIgnoreCase);
+ clean = clean.Replace("OU=", "", StringComparison.OrdinalIgnoreCase);
+ clean = clean.Replace("DC=", "", StringComparison.OrdinalIgnoreCase);
+ return clean.Replace(",", ".");
+ }
+
+
+ ///
+ /// Populate UC Path information
+ ///
+ private async Task PopulateUCPathInfoAsync(UserInfoResult result)
+ {
+ if (string.IsNullOrEmpty(result.EmployeeId))
+ return;
+
+ try
+ {
+ // Get UC Path person information
+ var person = await _ppsContext.VwPeople
+ .Where(p => p.Emplid == result.EmployeeId)
+ .FirstOrDefaultAsync();
+
+ if (person != null)
+ {
+ result.UCPathFlags = GetUCPathFlags(person);
+ }
+
+ // Get UC Path position information
+ var position = await _ppsContext.VwPersonJobPositions
+ .Where(p => p.Emplid == result.EmployeeId)
+ .OrderByDescending(p => p.Effdt)
+ .FirstOrDefaultAsync();
+
+ if (position != null)
+ {
+ result.UCPathJobCode = position.Jobcode;
+ result.UCPathJobDescription = position.JobcodeDesc;
+ result.UCPathDepartmentId = position.Deptid;
+ result.UCPathDepartmentDescription = position.DeptDesc;
+ result.UCPathJobStatus = position.JobStatus;
+ result.UCPathEmployeeStatus = position.EmplStatus;
+ result.UCPathJobStatusDescription = position.JobStatusDesc;
+ result.UCPathPositionEffectiveDate = position.PositionEffdt;
+ result.UCPathExpectedEndDate = position.ExpectedEndDate;
+ result.UCPathFTE = position.Fte;
+ result.UCPathUnion = position.UnionCd;
+
+ // Get reports to information
+ if (!string.IsNullOrEmpty(position.ReportsTo))
+ {
+ var reportsTo = await _ppsContext.VwPersonJobPositions
+ .Where(r => r.PositionNbr == position.ReportsTo)
+ .FirstOrDefaultAsync();
+
+ if (reportsTo != null)
+ {
+ result.UCPathReportsToName = $"{reportsTo.FirstName} {reportsTo.LastName}".Trim();
+ result.UCPathReportsToPosition = reportsTo.JobcodeDesc;
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateUCPathInfoAsync failed: {ex.Message}");
+ }
+
+ // Get UC Path History from the VwPersonJobPositionAll view
+ await PopulateUCPathHistoryAsync(result);
+ }
+
+ ///
+ /// Populate UC Path History information - equivalent to get_ucpath_history in userinfo.cfc
+ ///
+ private async Task PopulateUCPathHistoryAsync(UserInfoResult result)
+ {
+ if (string.IsNullOrEmpty(result.EmployeeId))
+ return;
+
+ try
+ {
+ var historyData = await _ppsContext.VwPersonJobPositionAlls
+ .Where(p => p.Emplid == result.EmployeeId)
+ .OrderByDescending(p => p.PositionEffdt)
+ .ThenByDescending(p => p.Effdt)
+ .ToListAsync();
+
+ foreach (var history in historyData)
+ {
+ var ucpathResult = new UCPathResult
+ {
+ JobCode = history.Jobcode,
+ JobCodeDescription = history.JobcodeDesc,
+ DepartmentId = history.Deptid,
+ DepartmentDescription = history.DeptDesc,
+ ActionDescription = history.ActionDescr,
+ PositionEffectiveDate = history.PositionEffdt.HasValue ? DateOnly.FromDateTime(history.PositionEffdt.Value) : null,
+ ReportsTo = GetReportsToName(history),
+ ReportsToPosition = GetReportsToPosition(history)
+ };
+
+ result.UCPathHistory.Add(ucpathResult);
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateUCPathHistoryAsync failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get reports to name from UC Path history record
+ ///
+ private string GetReportsToName(VwPersonJobPositionAll history)
+ {
+ if (string.IsNullOrEmpty(history.ReportsTo))
+ return string.Empty;
+
+ try
+ {
+ // Try to find the reports to person in the same view
+ var reportsTo = _ppsContext.VwPersonJobPositionAlls
+ .Where(r => r.PositionNbr == history.ReportsTo)
+ .FirstOrDefault();
+
+ if (reportsTo != null)
+ {
+ return $"{reportsTo.FirstName} {reportsTo.LastName}".Trim();
+ }
+
+ // Fallback to current positions view
+ var currentReportsTo = _ppsContext.VwPersonJobPositions
+ .Where(r => r.PositionNbr == history.ReportsTo)
+ .FirstOrDefault();
+
+ if (currentReportsTo != null)
+ {
+ return $"{currentReportsTo.FirstName} {currentReportsTo.LastName}".Trim();
+ }
+ }
+ catch
+ {
+ // Return empty string on any error
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// Get reports to position from UC Path history record
+ ///
+ private string GetReportsToPosition(VwPersonJobPositionAll history)
+ {
+ if (string.IsNullOrEmpty(history.ReportsTo))
+ return string.Empty;
+
+ try
+ {
+ // Try to find the reports to position in the same view
+ var reportsTo = _ppsContext.VwPersonJobPositionAlls
+ .Where(r => r.PositionNbr == history.ReportsTo)
+ .FirstOrDefault();
+
+ if (reportsTo != null)
+ {
+ // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
+ return reportsTo.JobcodeDesc ?? string.Empty;
+ }
+
+ // Fallback to current positions view
+ var currentReportsTo = _ppsContext.VwPersonJobPositions
+ .Where(r => r.PositionNbr == history.ReportsTo)
+ .FirstOrDefault();
+
+ if (currentReportsTo != null)
+ {
+ // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
+ return currentReportsTo.JobcodeDesc ?? string.Empty;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Fall back to returning string.Empty if DB query fails.
+ Console.WriteLine($"Warning: GetReportsToTitleAsync failed: {ex.Message}");
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// Populate ID Cards information
+ ///
+ private async Task PopulateIDCardsAsync(UserInfoResult result)
+ {
+ try
+ {
+ var cards = await (from card in _idCardsContext.IdCards
+ join status in _idCardsContext.DvtCardStatuses
+ on card.IdCardCurrentStatus equals status.DvtStatusCode into statusJoin
+ from status in statusJoin.DefaultIfEmpty()
+ join reason in _idCardsContext.DvtReasons
+ on card.IdcardDeactivatedReason equals reason.DvtReasonCode into reasonJoin
+ from reason in reasonJoin.DefaultIfEmpty()
+ where card.IdCardLoginId == result.LoginId
+ orderby card.IdCardAppliedDate descending
+ select new
+ {
+ Card = card,
+ StatusDescription = status != null ? status.DvtStatusDesc : "",
+ DeactivatedReasonDescription = reason != null ? reason.DvtReasonDesc : ""
+ }).ToListAsync();
+
+ foreach (var item in cards)
+ {
+ var card = item.Card;
+ result.IDCards.Add(new IDCardResult
+ {
+ Number = card.IdCardNumber?.ToString(),
+ DisplayName = card.IdCardDisplayName,
+ LastName = card.IdCardLastName,
+ Line2 = card.IdCardLine2,
+ StatusDescription = item.StatusDescription,
+ DeactivatedReason = item.DeactivatedReasonDescription,
+ Applied = card.IdCardAppliedDate,
+ Issued = card.IdCardIssueDate,
+ Deactivated = card.IdcardDeactivatedDate
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateIDCardsAsync failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Populate Keys information
+ ///
+ private async Task PopulateKeysAsync(UserInfoResult result)
+ {
+ try
+ {
+ var keyAssignments = await (from ka in _keysContext.KeyAssignments
+ join k in _keysContext.Keys on ka.KeyId equals k.KeyId
+ where ka.AssignedTo == result.MothraId && ka.Deleted == null
+ orderby ka.IssuedDate descending, ka.KeyId
+ select new { Assignment = ka, Key = k })
+ .ToListAsync();
+
+ foreach (var item in keyAssignments)
+ {
+ // Get issuer information from AAUD
+ var issuer = await _aaudContext.AaudUsers
+ .Where(u => u.MothraId == item.Assignment.IssuedBy)
+ .FirstOrDefaultAsync();
+
+ result.Keys.Add(new KeyResult
+ {
+ AccessDescription = item.Key.AccessDescription,
+ KeyNumber = item.Key.KeyNumber,
+ CutNumber = item.Assignment.CutNumber,
+ IssuedDate = item.Assignment.IssuedDate,
+ IssuedBy = issuer?.DisplayFullName
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateKeysAsync failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Populate Loans information
+ ///
+ private async Task PopulateLoansAsync(UserInfoResult result)
+ {
+ try
+ {
+ var loans = await _equipmentLoanContext.Loans
+ .Where(l => l.LoanPidm == result.Pidm)
+ .Include(l => l.LoanItems)
+ .ThenInclude(li => li.LoanitemAsset)
+ .OrderByDescending(l => l.LoanDate)
+ .ToListAsync();
+
+ foreach (var loan in loans)
+ {
+ foreach (var loanItem in loan.LoanItems)
+ {
+ result.Loans.Add(new LoanResult
+ {
+ AssetName = loanItem.LoanitemAsset.AssetName,
+ LoanDate = loan.LoanDate,
+ DueDate = loan.LoanDueDate,
+ Comments = loan.LoanComments
+ });
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: PopulateLoansAsync failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Populate Instinct information
+ ///
+ private async Task PopulateInstinctInfoAsync(UserInfoResult result, AaudUser user)
+ {
+ try
+ {
+ var instinctResult = await GetInstinctUserAsync(user.LastName, user.FirstName, user.MiddleName);
+ result.InstinctInfo = instinctResult;
+
+ if (instinctResult.Valid)
+ {
+ result.InstinctId = instinctResult.InstinctId;
+ result.InstinctUsername = instinctResult.Username;
+ result.InstinctRoles = instinctResult.Roles;
+ result.InstinctStatus = instinctResult.Status;
+ result.InstinctIsActive = instinctResult.IsActive;
+
+ if (!string.IsNullOrEmpty(instinctResult.PasswordExpiresAt) &&
+ DateTime.TryParse(instinctResult.PasswordExpiresAt, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var expireDate))
+ {
+ result.InstinctPasswordExpiresAt = expireDate;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ result.InstinctInfo = new InstinctResult { ErrorMessage = $"Populate Exception: {ex.Message}" };
+ }
+ }
+
+ private static string AdClean(string a)
+ {
+ if (string.IsNullOrEmpty(a)) return "";
+ var toReturn = a;
+ toReturn = toReturn.Replace("CN=", "", StringComparison.OrdinalIgnoreCase);
+ toReturn = toReturn.Replace("OU=", "", StringComparison.OrdinalIgnoreCase);
+ toReturn = toReturn.Replace("DC=", "", StringComparison.OrdinalIgnoreCase);
+ return toReturn;
+ }
+
+ private static string PermFormat(string a)
+ {
+ return AdClean(a).Replace(",", ".");
+ }
+
+ private static string AdFormat(string a, string[] domains)
+ {
+ var toReturn = AdClean(a);
+ foreach (var d in domains)
+ {
+ var domainWithCommas = d.Replace(".", ",");
+ toReturn = toReturn.Replace(domainWithCommas, d, StringComparison.OrdinalIgnoreCase);
+ }
+ var parts = toReturn.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(p => p.Trim())
+ .Reverse()
+ .ToList();
+ return string.Join("/", parts);
+ }
+
+ ///
+ /// Populate Active Directory information
+ ///
+ private static async Task PopulateActiveDirectoryInfoAsync(UserInfoResult result)
+ {
+ if (string.IsNullOrEmpty(result.LoginId))
+ {
+ return;
+ }
+
+ try
+ {
+ var uinformService = new UinformService();
+ var adUser = await uinformService.GetUser(samAccountName: result.LoginId);
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
+ if (adUser != null && !string.IsNullOrEmpty(adUser.SamAccountName))
+ {
+ result.ADDisplayName = adUser.DisplayName;
+ result.ADMail = adUser.Mail;
+ result.ADSamAccountName = adUser.SamAccountName;
+ result.ADUserPrincipalName = adUser.UserPrincipalName;
+ result.ADDistinguishedName = PermFormat(adUser.DistinguishedName ?? "");
+
+ var allGroups = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (adUser.MemberOf != null)
+ {
+ foreach (var g in adUser.MemberOf)
+ {
+ allGroups.Add(g);
+ }
+ }
+ if (adUser.MemberOfAll != null)
+ {
+ foreach (var g in adUser.MemberOfAll)
+ {
+ allGroups.Add(g);
+ }
+ }
+
+ var isProd = HttpHelper.Environment?.IsProduction() ?? false;
+ var domains = isProd
+ ? new[] { "ad3.ucdavis.edu", "ou.ad3.ucdavis.edu", "ucsvm.ucdavis.edu", "ad.vmth.ucdavis.edu", "vetmed.ucdavis.edu", "svm.ucdavis.edu" }
+ : new[] { "t3.ucdavis.edu" };
+ foreach (var groupDn in allGroups)
+ {
+ var formattedGroup = AdFormat(groupDn, domains);
+ if (!string.IsNullOrEmpty(formattedGroup))
+ {
+ result.ADMemberOf.Add(formattedGroup);
+ }
+ }
+
+ // Sort the groups
+ result.ADMemberOf = result.ADMemberOf.OrderBy(g => g, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error populating AD info: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get user photo data
+ ///
+ public static async Task GetUserPhotoAsync(string mailId, bool useAltPhoto = false)
+ {
+ // stubbed
+ return null;
+ }
+
+
+
+
+ ///
+ /// Get Instinct user information via GraphQL API
+ ///
+ private async Task GetInstinctUserAsync(string lastName, string firstName, string? middleName)
+ {
+ var result = new InstinctResult();
+
+ // Get access token
+ var accessToken = await GetInstinctAccessTokenAsync(result);
+ if (string.IsNullOrEmpty(accessToken))
+ {
+ return result;
+ }
+
+ // Build name variations for matching
+ var nameVariations = new List { firstName };
+
+ var firstNameParts = firstName.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ var first = firstNameParts.FirstOrDefault() ?? "";
+
+ if (firstNameParts.Length > 0 && !nameVariations.Contains(first))
+ {
+ nameVariations.Add(first);
+ }
+
+ if (firstNameParts.Length > 1)
+ {
+ var sb = new StringBuilder(first);
+ for (int i = 1; i < firstNameParts.Length; i++)
+ {
+ if (firstNameParts[i].Length > 0)
+ {
+ sb.Append(' ').Append(firstNameParts[i][0]);
+ var accum = sb.ToString();
+ if (!nameVariations.Contains(accum))
+ {
+ nameVariations.Add(accum);
+ }
+ }
+ }
+ }
+
+ var temp = nameVariations.ToList();
+ if (!string.IsNullOrEmpty(middleName))
+ {
+ var middleParts = middleName.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var name in temp)
+ {
+ foreach (var middlePart in middleParts)
+ {
+ if (middlePart.Length > 0)
+ {
+ var variation = $"{name} {middlePart[0]}";
+ if (!nameVariations.Contains(variation))
+ {
+ nameVariations.Add(variation);
+ }
+ }
+ }
+ }
+ }
+
+ // Create GraphQL query
+ var query = @"
+ query SearchUsers($name: String!) {
+ searchUsers(name: $name) {
+ id
+ initials
+ instinctId
+ isActive
+ isProtected
+ nameFirst
+ nameMiddle
+ nameLast
+ passwordExpiresAt
+ status
+ username
+ roles {
+ description
+ label
+ }
+ }
+ }";
+
+ // Execute GraphQL query
+ var apiUrl = _configuration["Instinct:ApiUrl"] ?? "https://uc-davis.api.instinctvet.com/";
+ var httpClient = _httpClientFactory.CreateClient();
+
+ var variablesJson = JsonSerializer.Serialize(new { name = lastName });
+ var queryUrl = $"{apiUrl}?query={Uri.EscapeDataString(query)}&variables={Uri.EscapeDataString(variablesJson)}";
+ httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}");
+
+ var response = await httpClient.GetAsync(queryUrl);
+ if (response.IsSuccessStatusCode)
+ {
+ var responseContent = await response.Content.ReadAsStringAsync();
+ var graphqlResponse = JsonSerializer.Deserialize(responseContent);
+
+ if (graphqlResponse?.Data?.SearchUsers != null)
+ {
+ bool foundMatch = false;
+ var matchedUser = graphqlResponse.Data.SearchUsers
+ .FirstOrDefault(user => nameVariations.Any(name => string.Equals(name, user.NameFirst, StringComparison.OrdinalIgnoreCase)));
+
+ if (matchedUser != null)
+ {
+ result.Valid = true;
+ result.Id = matchedUser.Id;
+ result.Initials = matchedUser.Initials;
+ result.InstinctId = matchedUser.InstinctId;
+ result.IsActive = matchedUser.IsActive;
+ result.IsProtected = matchedUser.IsProtected;
+ result.PasswordExpiresAt = matchedUser.PasswordExpiresAt;
+ result.Status = matchedUser.Status;
+ result.Username = matchedUser.Username;
+ result.Roles = matchedUser.Roles?
+ .Where(r => r.Label != null)
+ .Select(r => r.Label!)
+ .ToList() ?? new List();
+ foundMatch = true;
+ }
+ if (!foundMatch)
+ {
+ result.ErrorMessage = $"User found in API but no name match. Variations tried: {string.Join(", ", nameVariations)}. API users: {string.Join(", ", graphqlResponse.Data.SearchUsers.Select(u => $"{u.NameFirst} {u.NameLast}"))}";
+ }
+ }
+ else
+ {
+ result.ErrorMessage = "GraphQL response contained no searchUsers data.";
+ }
+ }
+ else
+ {
+ var responseContent = await response.Content.ReadAsStringAsync();
+ result.ErrorMessage = $"GraphQL query failed (Status: {response.StatusCode}): {responseContent}";
+ }
+ return result;
+ }
+
+ private static void AppendError(InstinctResult result, string msg)
+ {
+ result.ErrorMessage = string.IsNullOrEmpty(result.ErrorMessage)
+ ? msg
+ : $"{result.ErrorMessage} | {msg}";
+ }
+
+ ///
+ /// OAuth access token for Instinct
+ ///
+ private async Task GetInstinctAccessTokenAsync(InstinctResult result)
+ {
+ const string cacheKey = "instinct_access_token";
+
+ // Check cache first
+ if (_memoryCache.TryGetValue(cacheKey, out string? cachedToken) && !string.IsNullOrEmpty(cachedToken))
+ {
+ return cachedToken;
+ }
+
+ try
+ {
+ var apiUrl = _configuration["Instinct:ApiUrl"] ?? "https://uc-davis.api.instinctvet.com/";
+ if (!apiUrl.EndsWith('/'))
+ {
+ apiUrl += "/";
+ }
+ var tokenUrl = apiUrl + "auth/token";
+ var username = "ucdavisapi";
+ var password = HttpHelper.GetSetting("Credentials", "InstinctApi") ?? "";
+
+ if (string.IsNullOrEmpty(password))
+ {
+ string errMsg = "Password is null or empty in configuration";
+ Console.WriteLine($"[INSTINCT AUTH] {errMsg}");
+ AppendError(result, errMsg);
+ return null;
+ }
+
+ var httpClient = _httpClientFactory.CreateClient();
+ var formParams = new List>
+ {
+ new("username", username),
+ new("password", password),
+ new("grant_type", "password"),
+ new("scope", "api_access")
+ };
+
+ var formContent = new FormUrlEncodedContent(formParams);
+ Console.WriteLine("[INSTINCT AUTH] Sending token POST request...");
+ var response = await httpClient.PostAsync(tokenUrl, formContent);
+ Console.WriteLine($"[INSTINCT AUTH] Response Status Code: {response.StatusCode}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseContent = await response.Content.ReadAsStringAsync();
+ var tokenResponse = JsonSerializer.Deserialize(responseContent);
+ if (tokenResponse != null)
+ {
+ Console.WriteLine($"[INSTINCT AUTH] Deserialized Token Length: {tokenResponse.AccessToken.Length}");
+
+ if (!string.IsNullOrEmpty(tokenResponse.AccessToken))
+ {
+ // Cache token for slightly less than expiry time (subtract 2 hours as in CF code)
+ var cacheExpiry = TimeSpan.FromSeconds(tokenResponse.ExpiresIn - 7200); // 2 hours buffer
+ _memoryCache.Set(cacheKey, tokenResponse.AccessToken, cacheExpiry);
+
+ return tokenResponse.AccessToken;
+ }
+ }
+ }
+ else
+ {
+ var responseContent = await response.Content.ReadAsStringAsync();
+ string errMsg = $"Token POST request failed (Status: {response.StatusCode}): {responseContent}";
+ Console.WriteLine($"[INSTINCT AUTH] {errMsg}");
+ AppendError(result, errMsg);
+ }
+ }
+ catch (Exception ex)
+ {
+ string errMsg = $"Token request exception: {ex.Message}";
+ Console.WriteLine($"[INSTINCT AUTH] {errMsg}");
+ AppendError(result, errMsg);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Extract UC Path flags from VwPerson entity
+ ///
+ private static List GetUCPathFlags(dynamic person)
+ {
+ var flags = new List();
+ var flagDefinitions = new Dictionary
+ {
+ {"EmpAcdmcFederationFlg", "Academic Federation"},
+ {"EmpAcdmcFlg", "ACDMC"},
+ {"EmpAcdmcSenateFlg", "ACDMC Senate"},
+ {"EmpAcdmcStdtFlg", "ACDMC Stdt"},
+ {"EmpFacultyFlg", "Faculty"},
+ {"EmpLadderRankFlg", "Ladder Rank"},
+ {"EmpMgrFlg", "Manager"},
+ {"EmpMspCareerFlg", "MSP Career"},
+ {"EmpMspCareerPartialyrFlg", "MSP Career Partial Year"},
+ {"EmpMspCasualFlg", "MSP Casual"},
+ {"EmpMspCntrctFlg", "MSP Contract"},
+ {"EmpMspFlg", "MSP"},
+ {"EmpMspSeniorMgmtFlg", "MSP Senior Management"},
+ {"EmpSspCareerFlg", "SSP Career"},
+ {"EmpSspCareerPartialyrFlg", "SSP Career Partial Year"},
+ {"EmpSspCasualFlg", "SSP Casual"},
+ {"EmpSspCasualRestrictedFlg", "SSP Casual Restricted"},
+ {"EmpSspCntrctFlg", "SSP Contract"},
+ {"EmpSspFlg", "SSP"},
+ {"EmpSspFloaterFlg", "SSP Floater"},
+ {"EmpSspPerDiemFlg", "SSP Per diem"},
+ {"EmpSupvrFlg", "Supervisor"},
+ {"EmpTeachingFacultyFlg", "Teaching Faculty"},
+ {"EmpWosempFlg", "WOSEMP"}
+ };
+
+ var personType = person.GetType();
+ foreach (var flag in flagDefinitions)
+ {
+ var property = personType.GetProperty(flag.Key);
+ if (property != null)
+ {
+ var value = property.GetValue(person)?.ToString();
+ if (value == "Y")
+ {
+ flags.Add(flag.Value);
+ }
+ }
+ }
+ return flags;
+ }
+ }
+
+ // JSON for Instinct API
+ public class InstinctTokenResponse
+ {
+ [JsonPropertyName("access_token")]
+ public string AccessToken { get; set; } = string.Empty;
+
+ [JsonPropertyName("expires_in")]
+ public int ExpiresIn { get; set; }
+
+ [JsonPropertyName("token_type")]
+ public string TokenType { get; set; } = string.Empty;
+
+ [JsonPropertyName("scope")]
+ public string Scope { get; set; } = string.Empty;
+ }
+
+ public class InstinctGraphQLResponse
+ {
+ [JsonPropertyName("data")]
+ public InstinctGraphQLData? Data { get; set; }
+ }
+
+ public class InstinctGraphQLData
+ {
+ [JsonPropertyName("searchUsers")]
+ public List? SearchUsers { get; set; }
+ }
+
+ public class InstinctUser
+ {
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("initials")]
+ public string? Initials { get; set; }
+
+ [JsonPropertyName("instinctId")]
+ public string? InstinctId { get; set; }
+
+ [JsonPropertyName("isActive")]
+ public bool IsActive { get; set; }
+
+ [JsonPropertyName("isProtected")]
+ public bool IsProtected { get; set; }
+
+ [JsonPropertyName("nameFirst")]
+ public string? NameFirst { get; set; }
+
+ [JsonPropertyName("nameMiddle")]
+ public string? NameMiddle { get; set; }
+
+ [JsonPropertyName("nameLast")]
+ public string? NameLast { get; set; }
+
+ [JsonPropertyName("passwordExpiresAt")]
+ public string? PasswordExpiresAt { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("username")]
+ public string? Username { get; set; }
+
+ [JsonPropertyName("roles")]
+ // ReSharper disable once CollectionNeverUpdated.Global
+ public List? Roles { get; set; }
+ }
+
+ public class InstinctRole
+ {
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("label")]
+ public string? Label { get; set; }
+ }
+
+ // Helper class for permission processing
+ public class PermissionInfo
+ {
+ public int PermissionId { get; set; }
+ public string Permission { get; set; } = string.Empty;
+ public string Access { get; set; } = string.Empty;
+ public string Source { get; set; } = string.Empty;
+ public string SourceType { get; set; } = string.Empty;
+ }
+
+ // Result classes for student stored procedures
+ public class TermResult
+ {
+ public string? TermCode { get; set; }
+ }
+
+ public class PriorNameResult
+ {
+ public string? StudentName { get; set; }
+ public DateTime? ActivityDate { get; set; }
+ }
+
+ public class BannerIdResult
+ {
+ public string? SpridenId { get; set; }
+ }
+
+ public class ConfidentialResult
+ {
+ public string? SpbpersConfidInd { get; set; }
+ }
+
+ public class StudentStatusResult
+ {
+ public string? RegStatus { get; set; }
+ }
+
+ public class RegistrationStatusResult
+ {
+ public string? Status { get; set; }
+ }
+
+ public class MajorResult
+ {
+ public string? SgbstdnMajrCode1 { get; set; }
+ }
+
+ public class AllMajorsResult
+ {
+ public string? SgbstdnMajrCode1 { get; set; }
+ public string? SgbstdnMajrCode2 { get; set; }
+ }
+
+ public class ClassLevelResult
+ {
+ public string? SgvclssClasCode { get; set; }
+ }
+
+ public class ClassOfResult
+ {
+ public string? ClassOf { get; set; }
+ }
+
+ // Additional result classes for comprehensive student information
+ public class ConfidentialScopeResult
+ {
+ public string? ZtvconfDesc { get; set; }
+ }
+
+ public class BirthDateResult
+ {
+ public string? BirthDate { get; set; }
+ }
+
+ public class AgeResult
+ {
+ public string? Age { get; set; }
+ }
+
+ public class TermDescResult
+ {
+ public string? TermDesc { get; set; }
+ }
+
+ public class DegreeSoughtResult
+ {
+ public string? Degree1 { get; set; }
+ public string? Degree2 { get; set; }
+ }
+
+ public class AcademicStandingResult
+ {
+ public string? SgvstdnAstdDesc { get; set; }
+ }
+
+ public class GPAResult
+ {
+ public string? Gpa { get; set; }
+ }
+
+ public class ClassRankResult
+ {
+ public string? ClassRank { get; set; }
+ }
+
+ public class AdmitClassYearResult
+ {
+ public string? AdmitClassYear { get; set; }
+ }
+
+ public class AdmitTermResult
+ {
+ public string? AdmitTerm { get; set; }
+ }
+
+ public class DualDegreeResult
+ {
+ public string? IsDualDegree { get; set; }
+ }
+
+ public class DVMStudentResult
+ {
+ public string? IsDVMStudent { get; set; }
+ }
+
+ public class MPVMStudentResult
+ {
+ public string? IsMPVMStudent { get; set; }
+ }
+
+ public class EmployedResult
+ {
+ public string? WobeucePidm { get; set; }
+ }
+
+ public class StudentEmployeeIdResult
+ {
+ public string? EmployeeId { get; set; }
+ }
+
+ public class EmployerResult
+ {
+ public string? WobeuceDeptName { get; set; }
+ }
+
+ public class GenderResult
+ {
+ public string? Gender { get; set; }
+ }
+
+ public class EthnicityResult
+ {
+ public string? Ethnicity { get; set; }
+ }
+
+ public class NewEthnicityResult
+ {
+ public string? NewEthnicity { get; set; }
+ }
+
+ public class CAResidentResult
+ {
+ public string? ResidentFlag { get; set; }
+ }
+
+ public class USCitizenResult
+ {
+ public string? CitizenFlag { get; set; }
+ }
+
+ public class AddressResult
+ {
+ public string? Address { get; set; }
+ }
+
+ public class PhoneResult
+ {
+ public string? Phone { get; set; }
+ }
+
+ public class AuthDbRecord
+ {
+ public string Credential { get; set; } = string.Empty;
+ public string Username { get; set; } = string.Empty;
+ public string EncryptedString { get; set; } = string.Empty;
+ }
+}
+
diff --git a/web/Areas/Directory/Views/Card.cshtml b/web/Areas/Directory/Views/Card.cshtml
index 53158cec2..3eef7eabd 100644
--- a/web/Areas/Directory/Views/Card.cshtml
+++ b/web/Areas/Directory/Views/Card.cshtml
@@ -1,8 +1,8 @@
-@using Viper.Classes.SQLContext;
-@model Viper.Models.AAUD.AaudUser;
+@using Viper.Classes.SQLContext;
+@model Viper.Areas.Directory.Models.DirectoryUser;
@{
RAPSContext? rapsContext = (RAPSContext?)Context.RequestServices.GetService(typeof(RAPSContext));
- IUserHelper UserHelper = new UserHelper();
+ IUserHelper userHelper = new UserHelper();
ViewData["Title"] = "Directory";
}
Directory
@@ -21,16 +21,19 @@
color="primary"
label="Search all of UCD">
-
-
+ @if (Model.CanDisplayIDs)
+ {
+
+
+ }