diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index 47e53dd..888f092 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -26,7 +26,7 @@ public class LocationControllerTests public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List { new() { Id = 1, Name = "Site" } }, @@ -49,7 +49,7 @@ public class LocationControllerTests public async Task GetLocationList_PassesStatesFilterToService() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List(), @@ -58,7 +58,7 @@ public class LocationControllerTests PageSize = 10 }); - var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None); + var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None); result.Should().BeOfType(); service.VerifyAll(); @@ -68,13 +68,13 @@ public class LocationControllerTests public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException(new[] { new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.") })); - var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None); + var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; @@ -200,4 +200,90 @@ public class LocationControllerTests var notFound = result.Should().BeOfType().Subject; notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Location not found"); } + + [Fact] + public async Task GetLocationList_ForwardsSortParamsToService() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc")) + .ReturnsAsync(new PagedResult { Items = new List(), TotalCount = 0, Page = 1, PageSize = 10 }); + + await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None); + + service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc"), Times.Once); + } + + [Fact] + public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ValidationException(new[] + { + new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.") + })); + + var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Validation Error"); + response.Message.Should().Contain("sortBy"); + } + + [Fact] + public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), null, null)) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() + { + Id = 1, + Name = "STL8", + Address1 = "9 River Rd", + City = "St. Louis", + State = "MO", + Zip = "63101", + PhoneNumber = "555-0100", + Email = "poc@example.com", + AccountId = 3, + AccountName = "Acme Co", + Contacts = new List + { + new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" }, + new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" } + } + } + }, + TotalCount = 1, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value); + var row = json.RootElement.GetProperty("Data").EnumerateArray().Single(); + + row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field"); + row.GetProperty("Address").GetString().Should().Be("9 River Rd"); + row.GetProperty("City").GetString().Should().Be("St. Louis"); + row.GetProperty("State").GetString().Should().Be("MO"); + row.GetProperty("ZipCode").GetString().Should().Be("63101"); + row.GetProperty("Phone").GetString().Should().Be("555-0100"); + row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com"); + row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field"); + row.GetProperty("AccountId").GetInt32().Should().Be(3); + row.GetProperty("ClientName").GetString().Should().Be("Acme Co"); + row.GetProperty("AccountName").GetString().Should().Be("Acme Co"); + var contacts = row.GetProperty("Contacts"); + contacts.GetArrayLength().Should().Be(2); + contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane"); + contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs index 0dafeaf..595f78e 100644 --- a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs @@ -86,4 +86,129 @@ public class LocationDataServiceTests totalCount.Should().Be(2); items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO"); } + + private static async Task SeedRegistryAsync(ApplicationDbContext ctx) + { + ctx.Accounts.AddRange( + new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false }, + new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false }); + ctx.Locations.AddRange( + new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 }, + new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 }, + new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 }, + new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 }); + ctx.Contacts.AddRange( + new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" }, + new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" }, + new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" }, + new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true }, + new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" }); + await ctx.SaveChangesAsync(); + } + + [Theory] + [InlineData("STL", new[] { 10 }, "site code")] + [InlineData("Borealis", new[] { 11, 13 }, "client name")] + [InlineData("Prairie", new[] { 11 }, "street address")] + [InlineData("Miami", new[] { 13 }, "city")] + public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, term, null, CancellationToken.None); + + totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}"); + items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds); + } + + [Theory] + [InlineData("code", 11, 12, 13, 10)] + [InlineData("client", 10, 12, 11, 13)] + [InlineData("address", 13, 11, 12, 10)] + [InlineData("city", 11, 12, 13, 10)] + [InlineData("state", 13, 12, 10, 11)] + [InlineData("poc", 13, 12, 10, 11)] + public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak( + string sortBy, params int[] expectedIds) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc"); + + items.Select(l => l.Id).Should().ContainInOrder(expectedIds); + } + + [Fact] + public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc"); + + // DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must + // sort last; including the deleted row ("Removed Poc") would instead place it second. + items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11); + } + + [Fact] + public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc"); + + items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 }); + } + + [Fact] + public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways"); + + items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8"); + } + + [Fact] + public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 20, Name = "HOU1", City = "Dallas" }, + new Locations { Id = 21, Name = "AUS3", City = "Dallas" }, + new Locations { Id = 22, Name = "ELP4", City = "Dallas" }); + await ctx.SaveChangesAsync(); + + var service = new LocationDataService(ctx); + + var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc"); + var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc"); + + totalCount.Should().Be(3); + page1.Select(l => l.Id).Should().ContainInOrder(20, 21); + page2.Select(l => l.Id).Should().ContainInOrder(22); + } + + [Fact] + public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None); + + items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index c8986b6..2c986c8 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -131,8 +131,8 @@ public class LocationServiceTests { var data = new Mock(); IReadOnlyCollection? captured = default; - data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny())) - .Callback?, CancellationToken>((_, _, _, states, _) => captured = states) + data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states) .ReturnsAsync((new List(), 0)); await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None); @@ -149,8 +149,8 @@ public class LocationServiceTests { var data = new Mock(); IReadOnlyCollection? captured = default; - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) - .Callback?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter) .ReturnsAsync((new List(), 0)); var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None); @@ -173,14 +173,14 @@ public class LocationServiceTests && e.ErrorMessage.Contains("ZZ") && e.ErrorMessage.Contains("QQ") && !e.ErrorMessage.Contains("TX")); - data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny()), Times.Never); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes() { var data = new Mock(); - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((new List(), 0)); var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All); @@ -533,4 +533,97 @@ public class LocationServiceTests again.Should().BeFalse(); ctx.Locations.Should().BeEmpty(); } + + [Fact] + public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService() + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) => + { + capturedSort = sortBy; + capturedDirection = sortDirection; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC "); + + capturedSort.Should().Be("poc"); + capturedDirection.Should().Be("desc"); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", " ")] + public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults( + string? sortBy, + string? sortDirection) + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) => + { + capturedSort = s; + capturedDirection = d; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection); + + capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering"); + capturedDirection.Should().Be("asc"); + } + + [Theory] + [InlineData("rating")] + [InlineData("password; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortBy" + && e.ErrorMessage.Contains("code") + && e.ErrorMessage.Contains("poc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("ascending")] + [InlineData("DESC; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortDirection" + && e.ErrorMessage.Contains("asc") + && e.ErrorMessage.Contains("desc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows() + { + using var ctx = NewContext(); + SeedAccount(ctx, 9, "Acme Co"); + SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9; + await ctx.SaveChangesAsync(); + + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None); + + var row = page.Items.Should().ContainSingle().Subject; + row.AccountId.Should().Be(9); + row.AccountName.Should().Be("Acme Co"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs index 5b9cf9d..f4aef07 100644 --- a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs @@ -430,7 +430,7 @@ public class LocationSiteContactsTests public async Task List_LoadsPageContactsInSingleBatchedRead() { var data = new Mock(); - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((new List { new() { Id = 1, Name = "One" }, diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 9e970a6..a956819 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetLocationList")] - public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default) + public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default) { try { - var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken); + var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection); var data = pagedResult.Items.Select(l => new { @@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers ContactEmail = l.Email, Status = l.Status, AccountId = l.AccountId, + ClientName = l.AccountName, + AccountName = l.AccountName, Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList() }); diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 71be6ec..11cb89e 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation int pageSize, string? search, IReadOnlyCollection? states, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? sortBy = null, + string? sortDirection = null) { - var query = _context.Locations.AsNoTracking(); + IQueryable query = _context.Locations + .AsNoTracking() + .Include(l => l.Account); if (!string.IsNullOrWhiteSpace(search)) { query = query.Where(l => l.Name!.Contains(search) || - l.City!.Contains(search)); + l.Address1!.Contains(search) || + l.City!.Contains(search) || + l.Account != null && l.Account.Name!.Contains(search)); } if (states is { Count: > 0 }) @@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper())); } + query = ApplyListSort(query, sortBy, sortDirection); + var totalCount = await query.CountAsync(cancellationToken); var items = await query - .OrderBy(l => l.Name) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); @@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + private static IQueryable ApplyListSort(IQueryable query, string? sortBy, string? sortDirection) + { + var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase); + IOrderedQueryable ordered = (sortBy?.Trim().ToLowerInvariant()) switch + { + "code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name), + "client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name), + "address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1), + "city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City), + "state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State), + "poc" => descending + ? query.OrderByDescending(FirstActiveContactName) + : query.OrderBy(FirstActiveContactName), + _ => query.OrderBy(l => l.Name) + }; + + return ordered.ThenBy(l => l.Id); + } + + private static readonly System.Linq.Expressions.Expression> FirstActiveContactName = + l => l.Contacts + .Where(c => c.IsDeleted != true) + .OrderBy(c => c.SiteContactOrder) + .ThenBy(c => c.Id) + .Select(c => c.FirstName) + .FirstOrDefault(); + public async Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) { return await _context.Locations diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 582fb4b..5a068c1 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces Task ExistsAsync(int id); Task CountAsync(); - Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken); + /// + /// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order. + /// Sort options trail the token so existing call sites stay source-compatible. + /// + Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null); Task GetDetailByIdAsync(int id, CancellationToken cancellationToken); Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 1e437ba..58f0878 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs public string? Email { get; set; } public string? Status { get; set; } public int? AccountId { get; set; } + public string? AccountName { get; set; } public DateTime? CreatedDate { get; set; } public string? CreatedBy { get; set; } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index e140ece..64b275a 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -166,11 +166,15 @@ namespace SeaHaven.Services.Implementation int pageSize, string? search, string? states = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? sortBy = null, + string? sortDirection = null) { var stateFilter = NormalizeStateFilter(states); var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter); - var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken); + var sort = NormalizeSortColumn(sortBy); + var direction = NormalizeSortDirection(sortDirection); + var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken, sort, direction); // SH-138: one bounded contact read for the whole page, never per row. var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken); @@ -184,6 +188,42 @@ namespace SeaHaven.Services.Implementation }; } + internal static readonly IReadOnlyCollection SortableColumns = new[] { "code", "client", "address", "city", "state", "poc" }; + + internal static string? NormalizeSortColumn(string? sortBy) + { + if (string.IsNullOrWhiteSpace(sortBy)) + return null; + + var normalized = sortBy.Trim().ToLowerInvariant(); + if (!SortableColumns.Contains(normalized)) + { + throw new ValidationException(new[] + { + new ValidationFailure("sortBy", $"sortBy must be one of: {string.Join(", ", SortableColumns)}.") + }); + } + + return normalized; + } + + internal static string NormalizeSortDirection(string? sortDirection) + { + if (string.IsNullOrWhiteSpace(sortDirection)) + return "asc"; + + var normalized = sortDirection.Trim().ToLowerInvariant(); + if (normalized != "asc" && normalized != "desc") + { + throw new ValidationException(new[] + { + new ValidationFailure("sortDirection", "sortDirection must be asc or desc.") + }); + } + + return normalized; + } + internal static IReadOnlyCollection? NormalizeStateFilter(string? states) { if (string.IsNullOrWhiteSpace(states)) @@ -503,6 +543,7 @@ namespace SeaHaven.Services.Implementation Email = location.Email, Status = location.Status, AccountId = location.AccountId, + AccountName = location.Account?.Name, CreatedDate = location.CreatedDate, CreatedBy = location.createdby, Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts) diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 661adde..f05620a 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces Task LocationExistsAsync(int id); Task GetTotalLocationCountAsync(); - Task> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default); + Task> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default, string? sortBy = null, string? sortDirection = null); Task GetLocationDetailAsync(int id, CancellationToken cancellationToken); Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 2b11906..5acde4c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests public Task ExistsAsync(int id) => _inner.ExistsAsync(id); public Task CountAsync() => _inner.CountAsync(); public Task<(List Items, int TotalCount)> GetListPagedAsync( - int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken) - => _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken); + int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null) + => _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection); public Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) => _inner.GetDetailByIdAsync(id, cancellationToken); public Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)