From 1162c68596ac5f276b69604734e499dfc8a29c85 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 12:55:47 -0300 Subject: [PATCH] feat(vendors): add directory filters and details API (#25) * feat(vendors): add directory filters and details API * fix(vendors): preserve omitted status * fix(vendors): align facet filtering * fix(vendors): address directory review findings --- .../VendorControllerTests.cs | 169 +++++++++++ .../VendorDataServiceTests.cs | 269 ++++++++++++++++++ .../VendorServiceTests.cs | 213 ++++++++++++++ .../Controllers/VendorController.cs | 153 +++++++++- Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs | 5 +- ...0260720220000_AddVendorDirectoryDetails.cs | 41 +++ .../ApplicationDbContextModelSnapshot.cs | 9 + Data.SeaHavenIndustries/Models/Vendor.cs | 7 + .../Implementation/VendorDataService.cs | 234 +++++++++++++-- .../Interfaces/IVendorDataService.cs | 10 +- .../Properties/AssemblyInfo.cs | 3 + SeaHaven.Services/DTOs/VendorDTOs.cs | 19 ++ .../Implementation/VendorService.cs | 70 ++++- .../Interfaces/IVendorService.cs | 10 +- .../Validation/VendorValidation.cs | 48 ++++ 15 files changed, 1218 insertions(+), 42 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/VendorControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorServiceTests.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs create mode 100644 SeaHaven.DataServices/Properties/AssemblyInfo.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs new file mode 100644 index 0000000..2440734 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -0,0 +1,169 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using System.Text.Json; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorControllerTests +{ + private static VendorController NewController(Mock service) + { + var controller = new VendorController( + service.Object, + db: null!, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "42") + }, "Test")) + } + }; + + return controller; + } + + [Fact] + public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus() + { + var service = new Mock(); + service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new EditVendor_DTO + { + Id = 8, + CompanyName = "Updated Name" + }; + + var result = await controller.Update(8, model); + + result.Should().BeOfType(); + service.Verify(x => x.UpdateVendorAsync( + 8, + It.Is(dto => dto.IsActive == null), + "42"), Times.Once); + } + + [Fact] + public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged() + { + var service = new Mock(); + service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new EditVendor_DTO + { + Id = 8, + CompanyName = "Updated Name", + IsActive = false + }; + + await controller.Update(8, model); + + service.Verify(x => x.UpdateVendorAsync( + 8, + It.Is(dto => dto.IsActive == false), + "42"), Times.Once); + } + + [Fact] + public async Task Create_WithoutIsActive_DefaultsToActive() + { + var service = new Mock(); + service.Setup(x => x.CreateVendorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new Vendor_DTO + { + CompanyName = "New Co" + }; + + await controller.Create(model); + + service.Verify(x => x.CreateVendorAsync( + It.Is(dto => dto.IsActive == true), + "42"), Times.Once); + } + + [Theory] + [InlineData(true, "Active Co", "Inactive Co")] + [InlineData(false, "Inactive Co", "Active Co")] + public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors( + bool isActive, + string expected, + string excluded) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + context.Vendors.AddRange( + new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); + await context.SaveChangesAsync(); + + var controller = new VendorController( + Mock.Of(), + context, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + var result = await controller.GetFacets(isActive); + var payload = JsonSerializer.Serialize( + result.Should().BeOfType().Subject.Value); + + payload.Should().Contain(expected); + payload.Should().NotContain(excluded); + } + + [Fact] + public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + context.Vendors.AddRange( + new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); + await context.SaveChangesAsync(); + + var controller = new VendorController( + Mock.Of(), + context, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + var result = await controller.GetFacets(); + var payload = JsonSerializer.Serialize( + result.Should().BeOfType().Subject.Value); + + payload.Should().Contain("Active Co"); + payload.Should().Contain("Inactive Co"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs new file mode 100644 index 0000000..31c4940 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -0,0 +1,269 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorDataServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ApplicationDbContext NewSqlServerContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True") + .Options; + return new ApplicationDbContext(options); + } + + private static Vendor VendorWithJobs( + string company, + string city, + string state, + string trades, + int jobCount, + bool isActive = true) + { + return new Vendor + { + CompanyName = company, + ContactName = $"{company} Technician", + City = city, + State = state, + TradeSpecialties = trades, + IsActive = isActive, + IsDeleted = false, + Dispatches = Enumerable.Range(1, jobCount) + .Select(workOrderId => new Dispatch { WorkOrderId = workOrderId }) + .ToList() + }; + } + + private static async Task SeedBoundaryVendors(ApplicationDbContext context) + { + context.Vendors.AddRange( + VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49), + VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50), + VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100), + VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150)); + await context.SaveChangesAsync(); + } + + [Theory] + [InlineData("under-50", "Under Co")] + [InlineData("50-99", "Fifty Co")] + [InlineData("100-149", "Hundred Co")] + [InlineData("150-plus", "One Fifty Co")] + public async Task GetPagedAsync_AppliesExactJobBucketBoundaries( + string bucket, + string expectedCompany) + { + await using var context = NewContext(); + await SeedBoundaryVendors(context); + var service = new VendorDataService(context); + + var (items, totalCount) = await service.GetPagedAsync( + 1, + 12, + isActive: true, + jobBuckets: new[] { bucket }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be(expectedCompany); + } + + [Fact] + public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets() + { + await using var context = NewContext(); + await SeedBoundaryVendors(context); + var service = new VendorDataService(context); + + var (items, totalCount) = await service.GetPagedAsync( + 1, + 12, + isActive: true, + companies: new[] { "Under Co", "Hundred Co" }, + trades: new[] { "HVAC", "Plumbing" }, + locations: new[] { "Chicago, IL" }, + jobBuckets: new[] { "under-50", "100-149" }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be("Under Co"); + } + + [Fact] + public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory() + { + await using var context = NewContext(); + context.Vendors.AddRange( + VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1), + VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false)); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var active = await service.GetPagedAsync(1, 1, isActive: true); + var inactive = await service.GetPagedAsync(1, 1, isActive: false); + + active.TotalCount.Should().Be(1); + inactive.TotalCount.Should().Be(1); + active.Items.Single().CompanyName.Should().Be("Active Co"); + inactive.Items.Single().CompanyName.Should().Be("Inactive Co"); + } + + [Fact] + public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" }); + cityOnly.TotalCount.Should().Be(1); + cityOnly.Items.Single().CompanyName.Should().Be("CityOnly"); + + var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" }); + stateOnly.TotalCount.Should().Be(1); + stateOnly.Items.Single().CompanyName.Should().Be("StateOnly"); + + var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" }); + full.TotalCount.Should().Be(1); + full.Items.Single().CompanyName.Should().Be("FullLoc"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" }); + hvac.TotalCount.Should().Be(1); + hvac.Items.Single().CompanyName.Should().Be("HvacOnly"); + + var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" }); + hvacControls.TotalCount.Should().Be(1); + hvacControls.Items.Single().CompanyName.Should().Be("HvacControls"); + + var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" }); + plumb.TotalCount.Should().Be(0); + plumb.Items.Should().BeEmpty(); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(1); + plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(2); + plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle"); + + var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" }); + roofing.TotalCount.Should().Be(1); + roofing.Items.Single().CompanyName.Should().Be("Middle"); + } + + [Fact] + public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders() + { + await using var context = NewContext(); + var vendor = new Vendor + { + CompanyName = "History Co", + IsActive = true, + IsDeleted = false, + Dispatches = new List + { + new() + { + WorkOrderId = 100, + DispatchWorkOrders = new List + { + new() { WorkOrderId = 100 }, + new() { WorkOrderId = 101 } + } + }, + new() { WorkOrderId = 101 } + } + }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var result = await service.GetPagedAsync(1, 12, isActive: true); + + result.Items.Single().TotalJobs.Should().Be(2); + } + + [Fact] + public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph() + { + await using var context = NewContext(); + var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2); + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var service = new VendorDataService(context); + var result = await service.GetByIdAsync(vendor.Id); + + result.Should().NotBeNull(); + context.Entry(result!).State.Should().Be(EntityState.Unchanged); + context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse(); + result!.Dispatches.Should().BeNull(); + } + + [Theory] + [InlineData("trade")] + [InlineData("location")] + [InlineData("job-bucket")] + public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter) + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var query = filter switch + { + "trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }), + "location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }), + _ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" }) + }; + + var sql = query.ToQueryString(); + + sql.Should().Contain("SELECT"); + if (filter == "trade") + sql.Should().Contain("TradeSpecialties"); + if (filter == "location") + sql.Should().Contain("City"); + if (filter == "job-bucket") + sql.Should().Contain("UNION"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs new file mode 100644 index 0000000..1067869 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -0,0 +1,213 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorServiceTests +{ + private static VendorService NewService(Mock data) => + new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation()); + + [Fact] + public async Task CreateVendor_PersistsCompleteProfile() + { + var data = new Mock(); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; }); + + var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Gateway Plumbing", + ContactName = "Jordan Lee", + Email = "dispatch@gateway.example", + Phone = "555-0100", + CompanyPhone = "555-0199", + Address = "10 Lake St", + City = "Chicago", + State = "IL", + Zipcode = "60601", + TradeSpecialties = "Plumbing, Backflow", + GoogleMapsUrl = "https://maps.google.com/example", + Notes = "After-hours dispatch available", + IsActive = true + }, "42"); + + saved.Should().NotBeNull(); + saved!.ContactName.Should().Be("Jordan Lee"); + saved.TradeSpecialties.Should().Be("Plumbing, Backflow"); + saved.CompanyPhone.Should().Be("555-0199"); + saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example"); + saved.Notes.Should().Be("After-hours dispatch available"); + saved.City.Should().Be("Chicago"); + result.IsActive.Should().BeTrue(); + result.ContactName.Should().Be("Jordan Lee"); + } + + [Fact] + public async Task UpdateVendor_UpdatesProfileAndActiveState() + { + var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO + { + Name = "New", + ContactName = "Casey", + TradeSpecialties = "HVAC", + IsActive = false + }, "42"); + + existing.CompanyName.Should().Be("New"); + existing.ContactName.Should().Be("Casey"); + existing.TradeSpecialties.Should().Be("HVAC"); + existing.IsActive.Should().BeFalse(); + existing.LastModifierUserId.Should().Be(42); + result.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task DeleteVendor_DeactivatesWithoutDeletingHistory() + { + var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).DeleteVendorAsync(9, "42"); + + existing.IsActive.Should().BeFalse(); + existing.IsDeleted.Should().NotBeTrue(); + existing.DeletionTime.Should().BeNull(); + existing.LastModificationTime.Should().NotBeNull(); + } + + [Fact] + public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields() + { + var data = new Mock(); + data.Setup(x => x.GetPagedAsync( + 1, + 12, + "chicago", + false, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>())) + .ReturnsAsync((new[] + { + new Vendor + { + Id = 10, + CompanyName = "Inactive Vendor", + ContactName = "Taylor", + City = "Chicago", + State = "IL", + TradeSpecialties = "Electrical", + IsActive = false + } + }.AsEnumerable(), 1)); + + var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false); + + result.TotalCount.Should().Be(1); + result.Items.Single().Should().BeEquivalentTo(new + { + Id = 10, + Name = "Inactive Vendor", + ContactName = "Taylor", + City = "Chicago", + State = "IL", + TradeSpecialties = "Electrical", + IsActive = false + }, options => options.ExcludingMissingMembers()); + } + + [Fact] + public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() + { + var vendor = new Vendor + { + Id = 11, + CompanyName = "History Vendor", + Dispatches = new List + { + new() + { + WorkOrderId = 100, + DispatchWorkOrders = new List + { + new() { WorkOrderId = 100 }, + new() { WorkOrderId = 101 } + } + }, + new() { WorkOrderId = 101 } + } + }; + var data = new Mock(); + data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor); + + var result = await NewService(data).GetVendorByIdAsync(11); + + result.Should().NotBeNull(); + result!.TotalJobs.Should().Be(2); + } + + [Fact] + public async Task GetVendorsPaged_ForwardsFacetFilters() + { + var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" }; + var trades = new[] { "Backflow Preventers" }; + var locations = new[] { "Dallas, TX" }; + var jobBuckets = new[] { "100-149" }; + var data = new Mock(); + data.Setup(x => x.GetPagedAsync( + 1, + 25, + "gateway", + true, + companies, + trades, + locations, + jobBuckets)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await NewService(data).GetVendorsPagedAsync( + 1, + 25, + "gateway", + true, + companies, + trades, + locations, + jobBuckets); + + data.VerifyAll(); + } + + [Fact] + public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl() + { + var validator = new CreateVendorValidation(); + + var result = validator.Validate(new CreateVendorDTO + { + Name = "Gateway Plumbing", + ContactName = "", + GoogleMapsUrl = "http://maps.google.com/gateway" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); + result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..ecc4ccd 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -32,16 +32,43 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetVendorList")] - public async Task GetVendorList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetVendorList( + string? search = "", + int page = 1, + int pageSize = 10, + bool? isActive = true, + [FromQuery] string[]? companies = null, + [FromQuery] string[]? trades = null, + [FromQuery] string[]? locations = null, + [FromQuery] string[]? jobBuckets = null) { - var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search); + var pagedResult = await _vendorService.GetVendorsPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets); var data = pagedResult.Items.Select(v => new { v.Id, CompanyName = v.Name, + v.ContactName, v.Email, - v.Phone + v.Phone, + v.CompanyPhone, + v.Address, + v.City, + v.State, + Zip = v.Zipcode, + v.TradeSpecialties, + v.GoogleMapsUrl, + v.Notes, + v.IsActive, + v.TotalJobs }); return Ok(new Pagination_DTO @@ -56,17 +83,35 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id}")] [HttpGet("GetById")] - public async Task GetVendorById([FromQuery] int? id) + public async Task GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId) { - if (id == null) + var vendorId = id ?? queryId; + if (vendorId == null) return BadRequest(new Response { Status = "Error", Message = "Id is required" }); - var vendor = await _vendorService.GetVendorByIdAsync(id.Value); + var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value); if (vendor == null) return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); - return Ok(vendor); + return Ok(new + { + vendor.Id, + CompanyName = vendor.Name, + vendor.ContactName, + vendor.Email, + vendor.Phone, + vendor.CompanyPhone, + vendor.Address, + vendor.City, + vendor.State, + Zip = vendor.Zipcode, + vendor.TradeSpecialties, + vendor.GoogleMapsUrl, + vendor.Notes, + vendor.IsActive, + vendor.TotalJobs + }); } [HttpPost] @@ -77,7 +122,19 @@ namespace Api.SeaHavenIndustries.Controllers { var createDto = new CreateVendorDTO { - Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required") + Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"), + ContactName = model.ContactName, + Email = model.Email, + Phone = model.Phone, + CompanyPhone = model.CompanyPhone, + Address = model.Address, + City = model.City, + State = model.State, + Zipcode = model.Zip, + TradeSpecialties = model.TradeSpecialties, + GoogleMapsUrl = model.GoogleMapsUrl, + Notes = model.Notes, + IsActive = model.IsActive ?? true }; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -111,7 +168,19 @@ namespace Api.SeaHavenIndustries.Controllers var updateDto = new UpdateVendorDTO { - Name = model.CompanyName + Name = model.CompanyName, + ContactName = model.ContactName, + Email = model.Email, + Phone = model.Phone, + CompanyPhone = model.CompanyPhone, + Address = model.Address, + City = model.City, + State = model.State, + Zipcode = model.Zip, + TradeSpecialties = model.TradeSpecialties, + GoogleMapsUrl = model.GoogleMapsUrl, + Notes = model.Notes, + IsActive = model.IsActive }; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -126,7 +195,7 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } - catch (KeyNotFoundException) + catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); } @@ -138,7 +207,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete("{id}")] [HttpPost("Delete")] - public async Task Delete([FromRoute] int? id, [FromQuery] int? queryId = null) + public async Task Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null) { try { @@ -152,9 +221,9 @@ namespace Api.SeaHavenIndustries.Controllers return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); await _vendorService.DeleteVendorAsync(vendorId, userId); - return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" }); + return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" }); } - catch (KeyNotFoundException) + catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); } @@ -212,6 +281,64 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpGet("facets")] + public async Task GetFacets([FromQuery] bool? isActive = null) + { + var query = _db.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + var vendors = await query + .OrderByDescending(v => v.Id) + .ToListAsync(); + + var companies = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) + .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(v => v.CompanyName) + .Select(v => new + { + Name = v.CompanyName, + v.CompanyPhone, + v.Email, + v.Address, + v.City, + v.State, + Zip = v.Zip, + v.GoogleMapsUrl + }); + + var trades = vendors + .SelectMany(v => (v.TradeSpecialties ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value); + + var locations = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) + .Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) + .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) + .OrderBy(location => location.Label); + + return Ok(new + { + Companies = companies, + Trades = trades, + Locations = locations, + JobBuckets = new[] + { + new { Id = "under-50", Label = "Under 50" }, + new { Id = "50-99", Label = "50–99" }, + new { Id = "100-149", Label = "100–149" }, + new { Id = "150-plus", Label = "150+" } + } + }); + } + [HttpGet("{id:int}/portal-token")] public async Task GetPortalToken(int id) { diff --git a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs index f283379..d2a4717 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -6,12 +6,15 @@ namespace Api.SeaHavenIndustries.DTOs public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zip { get; set; } public string? TradeSpecialties { get; set; } - public bool IsActive { get; set; } = true; + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool? IsActive { get; set; } } public class EditVendor_DTO : Vendor_DTO diff --git a/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs b/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs new file mode 100644 index 0000000..ad4b3b1 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs @@ -0,0 +1,41 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260720220000_AddVendorDirectoryDetails")] + public partial class AddVendorDirectoryDetails : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompanyPhone", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "GoogleMapsUrl", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Notes", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors"); + migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors"); + migrationBuilder.DropColumn(name: "Notes", table: "Vendors"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 43e63e3..190a690 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1733,6 +1733,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("CompanyName") .HasColumnType("nvarchar(max)"); + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + b.Property("ContactName") .HasColumnType("nvarchar(max)"); @@ -1748,6 +1751,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Email") .HasColumnType("nvarchar(max)"); + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + b.Property("IsActive") .HasColumnType("bit"); @@ -1760,6 +1766,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("LastModifierUserId") .HasColumnType("int"); + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + b.Property("Phone") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index d6f623a..d1d48b7 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations.Schema; + namespace Data.SeaHavenIndustries { public class Vendor : FullAuditEntity @@ -6,12 +8,17 @@ namespace Data.SeaHavenIndustries public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zip { get; set; } public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } public bool IsActive { get; set; } = true; + [NotMapped] + public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 6eb2eeb..273dec0 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using System.Reflection; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -15,13 +17,16 @@ namespace SeaHaven.DataServices.Implementation public async Task GetByIdAsync(int id) { - return await _context.Vendors.FindAsync(id); + return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id); } public async Task GetByIdWithDetailsAsync(int id) { - return await _context.Vendors - .FirstOrDefaultAsync(v => v.Id == id); + return await ProjectWithTotalJobs( + _context.Vendors + .AsNoTracking() + .Where(v => v.Id == id)) + .FirstOrDefaultAsync(); } public async Task> GetAllAsync() @@ -32,24 +37,23 @@ namespace SeaHaven.DataServices.Implementation public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, - string? search = null) + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) { - search ??= ""; + var projectedQuery = BuildDirectoryQuery( + search, + isActive, + companies, + trades, + locations, + jobBuckets); - var query = _context.Vendors.Where(v => v.IsActive); - - if (!string.IsNullOrWhiteSpace(search)) - { - var s = search.ToLower(); - query = query.Where(v => - (v.CompanyName ?? "").ToLower().Contains(s) || - (v.ContactName ?? "").ToLower().Contains(s) || - (v.Email ?? "").ToLower().Contains(s) || - (v.TradeSpecialties ?? "").ToLower().Contains(s)); - } - - var totalCount = await query.CountAsync(); - var items = await query + var totalCount = await projectedQuery.CountAsync(); + var items = await projectedQuery .OrderByDescending(v => v.Id) .Skip((page - 1) * pageSize) .Take(pageSize) @@ -58,6 +62,198 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + internal IQueryable BuildDirectoryQuery( + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) + { + var query = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + if (!string.IsNullOrWhiteSpace(search)) + { + var normalizedSearch = search.ToLower(); + query = query.Where(v => + (v.CompanyName ?? "").ToLower().Contains(normalizedSearch) || + (v.ContactName ?? "").ToLower().Contains(normalizedSearch) || + (v.Email ?? "").ToLower().Contains(normalizedSearch) || + (v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) || + (v.City ?? "").ToLower().Contains(normalizedSearch) || + (v.State ?? "").ToLower().Contains(normalizedSearch) || + (v.Zip ?? "").ToLower().Contains(normalizedSearch)); + } + + var selectedCompanies = companies? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedCompanies.Length > 0) + query = query.Where(v => + selectedCompanies.Contains((v.CompanyName ?? "").ToLower())); + + var selectedTrades = trades? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedTrades.Length > 0) + query = query.Where(BuildTradePredicate(selectedTrades)); + + var selectedLocations = locations? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedLocations.Length > 0) + query = query.Where(BuildLocationPredicate(selectedLocations)); + + var result = ProjectWithTotalJobs(query); + var selectedJobBuckets = jobBuckets? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedJobBuckets.Length == 0) + return result; + + var under50 = selectedJobBuckets.Contains("under-50"); + var from50To99 = selectedJobBuckets.Contains("50-99"); + var from100To149 = selectedJobBuckets.Contains("100-149"); + var from150 = selectedJobBuckets.Contains("150-plus"); + + return result.Where(v => + (under50 && v.TotalJobs < 50) || + (from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) || + (from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) || + (from150 && v.TotalJobs >= 150)); + } + + private IQueryable ProjectWithTotalJobs(IQueryable query) => + query.Select(v => new Vendor + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + Email = v.Email, + Phone = v.Phone, + CompanyPhone = v.CompanyPhone, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + TradeSpecialties = v.TradeSpecialties, + GoogleMapsUrl = v.GoogleMapsUrl, + Notes = v.Notes, + IsActive = v.IsActive, + IsDeleted = v.IsDeleted, + TotalJobs = _context.Dispatches + .Where(dispatch => + dispatch.VendorId == v.Id && + dispatch.WorkOrderId.HasValue) + .Select(dispatch => dispatch.WorkOrderId!.Value) + .Union( + _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id) + .Select(link => link.WorkOrderId)) + .Count() + }); + + private static readonly MethodInfo StringConcatMethod = + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!; + + private static Expression> BuildTradePredicate( + IReadOnlyCollection selectedTrades) + { + var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var specialties = Expression.Coalesce( + Expression.Property(vendor, nameof(Vendor.TradeSpecialties)), + Expression.Constant(string.Empty)); + var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes); + + var delimiters = new[] { ", ", "," }; + Expression? body = null; + + foreach (var trade in selectedTrades) + { + var token = Expression.Constant(trade); + + body = OrElse(body, Expression.Equal(lowered, token)); + + foreach (var delimiter in delimiters) + { + var delim = Expression.Constant(delimiter); + var tokenWithTrailing = Concat(token, delim); + var leadingToken = Concat(delim, token); + var wrappedToken = Concat(leadingToken, delim); + + body = OrElse(body, StartsWith(lowered, tokenWithTrailing)); + body = OrElse(body, EndsWith(lowered, leadingToken)); + body = OrElse(body, Contains(lowered, wrappedToken)); + } + } + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + + private static Expression> BuildLocationPredicate( + IReadOnlyCollection selectedLocations) + { + var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var label = BuildLocationLabelBody(vendor); + var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes); + + Expression? body = null; + foreach (var location in selectedLocations) + body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location))); + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + + private static Expression BuildLocationLabelBody(ParameterExpression vendor) + { + var city = Expression.Property(vendor, nameof(Vendor.City)); + var state = Expression.Property(vendor, nameof(Vendor.State)); + var cityBlank = IsBlank(city); + var stateBlank = IsBlank(state); + + var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state); + + var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state); + var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState); + return Expression.Condition(cityBlank, onlyState, cityOrBoth); + } + + private static Expression IsBlank(Expression value) + { + var isNull = Expression.Equal(value, Expression.Constant(null, value.Type)); + var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes); + var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty)); + return Expression.Condition(isNull, Expression.Constant(true), isEmpty); + } + + private static Expression Concat(Expression left, Expression right) => + Expression.Call(StringConcatMethod, left, right); + + private static Expression StartsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value); + + private static Expression EndsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value); + + private static Expression Contains(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value); + + private static Expression OrElse(Expression? accumulated, Expression next) => + accumulated is null ? next : Expression.OrElse(accumulated, next); + public async Task AddAsync(Vendor vendor) { await _context.Vendors.AddAsync(vendor); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 6458b4f..2dbd0c1 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); Task> GetAllAsync(); - Task<(IEnumerable Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null); + Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null); Task AddAsync(Vendor vendor); Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); diff --git a/SeaHaven.DataServices/Properties/AssemblyInfo.cs b/SeaHaven.DataServices/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..75fc406 --- /dev/null +++ b/SeaHaven.DataServices/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")] diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 3a58934..6c1cd82 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -4,33 +4,52 @@ namespace SeaHaven.Services.DTOs { public int Id { get; set; } public string? Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public int TotalJobs { get; set; } + public bool IsActive { get; set; } } public class CreateVendorDTO { public required string Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } = true; } public class UpdateVendorDTO { public string? Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool? IsActive { get; set; } } } diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index eccc405..6ba8e71 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -29,7 +29,7 @@ namespace SeaHaven.Services.Implementation public async Task GetVendorByIdAsync(int id) { - var vendor = await _vendorDataService.GetByIdAsync(id); + var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id); return vendor == null ? null : MapToDTO(vendor); } @@ -45,9 +45,25 @@ namespace SeaHaven.Services.Implementation return vendors.Select(MapToDTO); } - public async Task> GetVendorsPagedAsync(int page, int pageSize, string? search = null) + public async Task> GetVendorsPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) { - var (items, totalCount) = await _vendorDataService.GetPagedAsync(page, pageSize, search); + var (items, totalCount) = await _vendorDataService.GetPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets); return new PagedResult { Items = items.Select(MapToDTO).ToList(), @@ -68,12 +84,18 @@ namespace SeaHaven.Services.Implementation var vendor = new Vendor { CompanyName = dto.Name, + ContactName = dto.ContactName, Email = dto.Email, Phone = dto.Phone, + CompanyPhone = dto.CompanyPhone, Address = dto.Address, City = dto.City, State = dto.State, Zip = dto.Zipcode, + TradeSpecialties = dto.TradeSpecialties, + GoogleMapsUrl = dto.GoogleMapsUrl, + Notes = dto.Notes, + IsActive = dto.IsActive, CreatedDate = DateTime.UtcNow, createdby = userId }; @@ -95,12 +117,18 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException($"Vendor with ID {id} not found"); if (dto.Name != null) vendor.CompanyName = dto.Name; + if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; if (dto.Phone != null) vendor.Phone = dto.Phone; + if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone; if (dto.Address != null) vendor.Address = dto.Address; if (dto.City != null) vendor.City = dto.City; if (dto.State != null) vendor.State = dto.State; if (dto.Zipcode != null) vendor.Zip = dto.Zipcode; + if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties; + if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl; + if (dto.Notes != null) vendor.Notes = dto.Notes; + if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) @@ -116,9 +144,10 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); - vendor.IsDeleted = true; - vendor.DeleterUserId = userId; - vendor.DeletionTime = DateTime.UtcNow; + vendor.IsActive = false; + vendor.LastModificationTime = DateTime.UtcNow; + if (int.TryParse(userId, out int userIdInt)) + vendor.LastModifierUserId = userIdInt; await _vendorDataService.UpdateAsync(vendor); } @@ -139,13 +168,40 @@ namespace SeaHaven.Services.Implementation { Id = vendor.Id, Name = vendor.CompanyName, + ContactName = vendor.ContactName, Email = vendor.Email, Phone = vendor.Phone, + CompanyPhone = vendor.CompanyPhone, Address = vendor.Address, City = vendor.City, State = vendor.State, - Zipcode = vendor.Zip + Zipcode = vendor.Zip, + TradeSpecialties = vendor.TradeSpecialties, + GoogleMapsUrl = vendor.GoogleMapsUrl, + Notes = vendor.Notes, + IsActive = vendor.IsActive, + TotalJobs = vendor.Dispatches == null + ? vendor.TotalJobs + : CountDistinctWorkOrders(vendor) }; } + + private static int CountDistinctWorkOrders(Vendor vendor) + { + if (vendor.Dispatches == null) + return 0; + + return vendor.Dispatches + .SelectMany(dispatch => + { + var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId) + ?? Enumerable.Empty(); + return dispatch.WorkOrderId.HasValue + ? linked.Append(dispatch.WorkOrderId.Value) + : linked; + }) + .Distinct() + .Count(); + } } } diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 13d996b..aad7b13 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -7,7 +7,15 @@ namespace SeaHaven.Services.Interfaces Task GetVendorByIdAsync(int id); Task GetVendorByIdWithDetailsAsync(int id); Task> GetAllVendorsAsync(); - Task> GetVendorsPagedAsync(int page, int pageSize, string? search = null); + Task> GetVendorsPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null); Task CreateVendorAsync(CreateVendorDTO dto, string userId); Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId); Task DeleteVendorAsync(int id, string userId); diff --git a/SeaHaven.Services/Validation/VendorValidation.cs b/SeaHaven.Services/Validation/VendorValidation.cs index b2f5c03..acce074 100644 --- a/SeaHaven.Services/Validation/VendorValidation.cs +++ b/SeaHaven.Services/Validation/VendorValidation.cs @@ -19,10 +19,22 @@ namespace SeaHaven.Services.Validation .EmailAddress().WithMessage("Invalid email address") .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.ContactName) + .NotEmpty().WithMessage("Technician name is required") + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters"); + + RuleFor(x => x.TradeSpecialties) + .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") + .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); + RuleFor(x => x.Phone) .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") .When(x => !string.IsNullOrEmpty(x.Phone)); + RuleFor(x => x.CompanyPhone) + .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); + RuleFor(x => x.Address) .MaximumLength(500).WithMessage("Address cannot exceed 500 characters") .When(x => !string.IsNullOrEmpty(x.Address)); @@ -38,6 +50,14 @@ namespace SeaHaven.Services.Validation RuleFor(x => x.Zipcode) .MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters") .When(x => !string.IsNullOrEmpty(x.Zipcode)); + + RuleFor(x => x.GoogleMapsUrl) + .Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL") + .When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); } } @@ -57,10 +77,23 @@ namespace SeaHaven.Services.Validation .EmailAddress().WithMessage("Invalid email address") .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.ContactName) + .NotEmpty().WithMessage("Technician name is required") + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") + .When(x => x.ContactName != null); + + RuleFor(x => x.TradeSpecialties) + .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") + .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); + RuleFor(x => x.Phone) .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") .When(x => !string.IsNullOrEmpty(x.Phone)); + RuleFor(x => x.CompanyPhone) + .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); + RuleFor(x => x.Address) .MaximumLength(500).WithMessage("Address cannot exceed 500 characters") .When(x => !string.IsNullOrEmpty(x.Address)); @@ -76,6 +109,21 @@ namespace SeaHaven.Services.Validation RuleFor(x => x.Zipcode) .MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters") .When(x => !string.IsNullOrEmpty(x.Zipcode)); + + RuleFor(x => x.GoogleMapsUrl) + .Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL") + .When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); } + + } + + internal static class VendorValidationRules + { + internal static bool BeValidHttpsUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; } }