From 8c4102b1cf4cd93b137d0e38bd633147979e39fc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:36:11 -0300 Subject: [PATCH] feat(locations): add sites options endpoint and vendor contactName (#20) * fix(migrations): make Phase6 completion slide-over migration idempotent Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata so partial environments can apply Phase 6 safely. * feat(locations): add sites options endpoint and vendor contactName Expose GET /api/locations/sites for the WO create wizard and include vendor contactName on dropdown responses. * fix(work-orders): make stacked tip build for CI quality gates --------- Co-authored-by: Arthur Bassi --- .../LocationControllerSitesTests.cs | 109 ++++++++++++++++++ .../Controllers/LocationController.cs | 7 ++ .../Controllers/VendorController.cs | 2 + .../Implementation/LocationDataService.cs | 18 +++ .../Interfaces/ILocationDataService.cs | 4 + SeaHaven.Services/DTOs/LocationDTOs.cs | 8 ++ .../Implementation/LocationService.cs | 14 +++ .../Interfaces/ILocationService.cs | 1 + 8 files changed, 163 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs new file mode 100644 index 0000000..5074c22 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -0,0 +1,109 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationControllerSitesTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationController NewController(ApplicationDbContext ctx) + { + var dataService = new LocationDataService(ctx); + var service = new LocationService( + dataService, + Mock.Of(), + Mock.Of()); + + var controller = new LocationController(service, dataService) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + return controller; + } + + private static async Task SeedSitesAsync(ApplicationDbContext ctx) + { + ctx.Locations.AddRange( + new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" }, + new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" }, + new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" }, + new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites(); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(4); + data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8"); + data[0].Should().BeEquivalentTo(new SiteOptionDTO + { + Id = 2, + Code = "BK5", + City = "Dallas", + State = "TX" + }); + } + + [Fact] + public async Task GetSites_FiltersByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("bk5"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().ContainSingle(); + data[0].Code.Should().Be("BK5"); + } + + [Fact] + public async Task GetSites_FiltersByCity() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("Dallas"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(2); + data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8"); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 4bfba79..3d671b2 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -26,6 +26,13 @@ namespace Api.SeaHavenIndustries.Controllers _locationDataService = locationDataService; } + [HttpGet("sites")] + public async Task GetSites(string? search = null) + { + var data = await _locationService.GetSiteOptionsAsync(search); + return Ok(data); + } + [HttpGet("GetLocationList")] public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10) { diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..9c25ceb 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -174,6 +174,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, v.Address, v.City, @@ -191,6 +192,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 65c46df..b48cd5b 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var query = _context.Locations.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.ToLower(); + query = query.Where(l => + (l.Name ?? "").ToLower().Contains(s) || + (l.City ?? "").ToLower().Contains(s)); + } + + return await query + .OrderBy(l => l.Name) + .Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State)) + .ToListAsync(); + } + public async Task AddAsync(Locations location) { location.CreatedDate = DateTime.Now; diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 2865e42..679528e 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -2,6 +2,8 @@ using Data.SeaHavenIndustries; namespace SeaHaven.DataServices.Interfaces { + public record SiteOptionRow(int Id, string? Code, string? City, string? State); + public interface ILocationDataService { Task GetByIdAsync(int id); @@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces // For addressbook endpoint - returns simplified DTO Task<(IEnumerable Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); + Task AddAsync(Locations location); Task UpdateAsync(Locations location); Task DeleteAsync(int id); diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 09e41b6..9b518d0 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -36,4 +36,12 @@ namespace SeaHaven.Services.DTOs public string? Zipcode { get; set; } public int? AccountId { get; set; } } + + public class SiteOptionDTO + { + public int Id { get; set; } + public string? Code { get; set; } + public string? City { get; set; } + public string? State { get; set; } + } } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index c625192..accf6ba 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -64,6 +64,20 @@ namespace SeaHaven.Services.Implementation }; } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var rows = await _locationDataService.GetSiteOptionsAsync(search); + return rows + .Select(r => new SiteOptionDTO + { + Id = r.Id, + Code = r.Code, + City = r.City, + State = r.State + }) + .ToList(); + } + public async Task CreateLocationAsync(CreateLocationDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 7bf9d4e..dd70421 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -9,6 +9,7 @@ namespace SeaHaven.Services.Interfaces Task> GetAllLocationsAsync(); Task> GetLocationsByAccountIdAsync(int accountId); Task> GetLocationsPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); Task CreateLocationAsync(CreateLocationDTO dto, string userId); Task UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId); Task DeleteLocationAsync(int id, string userId);