From 1a5587126e41fe96f706671518aef91d8d62ed8e Mon Sep 17 00:00:00 2001 From: brandizzi Date: Mon, 20 Jul 2026 23:47:55 -0300 Subject: [PATCH] feat(vendors): add directory filters and details API --- .../VendorDataServiceTests.cs | 113 ++++++++++ .../VendorServiceTests.cs | 213 ++++++++++++++++++ .../Controllers/VendorController.cs | 148 ++++++++++-- Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs | 3 + ...0260720220000_AddVendorDirectoryDetails.cs | 41 ++++ .../ApplicationDbContextModelSnapshot.cs | 9 + Data.SeaHavenIndustries/Models/Vendor.cs | 3 + .../Implementation/VendorDataService.cs | 123 +++++++++- .../Interfaces/IVendorDataService.cs | 10 +- SeaHaven.Services/DTOs/VendorDTOs.cs | 19 ++ .../Implementation/VendorService.cs | 66 +++++- .../Interfaces/IVendorService.cs | 10 +- .../Validation/VendorValidation.cs | 48 ++++ 13 files changed, 781 insertions(+), 25 deletions(-) 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 diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs new file mode 100644 index 0000000..d94f892 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -0,0 +1,113 @@ +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 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"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs new file mode 100644 index 0000000..91c1f65 --- /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.GetByIdAsync(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..a1ae149 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 }; 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,59 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpGet("facets")] + public async Task GetFacets() + { + var vendors = await _db.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false) + .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..0d167da 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -6,11 +6,14 @@ 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 string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } public bool IsActive { get; set; } = true; } 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 7479f75..e42d930 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1811,6 +1811,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("CompanyName") .HasColumnType("nvarchar(max)"); + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + b.Property("ContactName") .HasColumnType("nvarchar(max)"); @@ -1826,6 +1829,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Email") .HasColumnType("nvarchar(max)"); + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + b.Property("IsActive") .HasColumnType("bit"); @@ -1838,6 +1844,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..4fb48b3 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -6,11 +6,14 @@ 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; public ICollection? Dispatches { get; set; } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 6eb2eeb..648764b 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -15,12 +16,17 @@ namespace SeaHaven.DataServices.Implementation public async Task GetByIdAsync(int id) { - return await _context.Vendors.FindAsync(id); + return await _context.Vendors + .Include(v => v.Dispatches!) + .ThenInclude(dispatch => dispatch.DispatchWorkOrders) + .FirstOrDefaultAsync(v => v.Id == id); } public async Task GetByIdWithDetailsAsync(int id) { return await _context.Vendors + .Include(v => v.Dispatches!) + .ThenInclude(dispatch => dispatch.DispatchWorkOrders) .FirstOrDefaultAsync(v => v.Id == id); } @@ -32,11 +38,19 @@ 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 query = _context.Vendors.Where(v => v.IsActive); + var query = _context.Vendors.Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); if (!string.IsNullOrWhiteSpace(search)) { @@ -45,11 +59,71 @@ namespace SeaHaven.DataServices.Implementation (v.CompanyName ?? "").ToLower().Contains(s) || (v.ContactName ?? "").ToLower().Contains(s) || (v.Email ?? "").ToLower().Contains(s) || - (v.TradeSpecialties ?? "").ToLower().Contains(s)); + (v.TradeSpecialties ?? "").ToLower().Contains(s) || + (v.City ?? "").ToLower().Contains(s) || + (v.State ?? "").ToLower().Contains(s) || + (v.Zip ?? "").ToLower().Contains(s)); + } + + 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(v => selectedLocations.Contains(((v.City ?? "") + ", " + (v.State ?? "")).ToLower())); + + var selectedJobBuckets = jobBuckets? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedJobBuckets.Length > 0) + { + var under50 = selectedJobBuckets.Contains("under-50"); + var from50To99 = selectedJobBuckets.Contains("50-99"); + var from100To149 = selectedJobBuckets.Contains("100-149"); + var from150 = selectedJobBuckets.Contains("150-plus"); + + var vendorsWithDispatches = await query + .Include(v => v.Dispatches!) + .ThenInclude(dispatch => dispatch.DispatchWorkOrders) + .OrderByDescending(v => v.Id) + .ToListAsync(); + var filtered = vendorsWithDispatches.Where(vendor => + { + var totalJobs = CountDistinctWorkOrders(vendor); + return (under50 && totalJobs < 50) || + (from50To99 && totalJobs is >= 50 and <= 99) || + (from100To149 && totalJobs is >= 100 and <= 149) || + (from150 && totalJobs >= 150); + }).ToList(); + + return ( + filtered.Skip((page - 1) * pageSize).Take(pageSize), + filtered.Count); } var totalCount = await query.CountAsync(); var items = await query + .Include(v => v.Dispatches!) + .ThenInclude(dispatch => dispatch.DispatchWorkOrders) .OrderByDescending(v => v.Id) .Skip((page - 1) * pageSize) .Take(pageSize) @@ -58,6 +132,47 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + 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(); + } + + 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); + Expression? body = null; + + foreach (var trade in selectedTrades) + { + var contains = Expression.Call( + lowered, + nameof(string.Contains), + Type.EmptyTypes, + Expression.Constant(trade)); + body = body == null ? contains : Expression.OrElse(body, contains); + } + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + 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.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..bc3e2fc 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -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,38 @@ 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 = 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; } }