mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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
This commit is contained in:
parent
32b9113057
commit
1162c68596
15 changed files with 1218 additions and 42 deletions
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal file
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal file
|
|
@ -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<IVendorService> 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<IVendorService>();
|
||||
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
||||
.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<OkObjectResult>();
|
||||
service.Verify(x => x.UpdateVendorAsync(
|
||||
8,
|
||||
It.Is<UpdateVendorDTO>(dto => dto.IsActive == null),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
||||
.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<UpdateVendorDTO>(dto => dto.IsActive == false),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutIsActive_DefaultsToActive()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.CreateVendorAsync(It.IsAny<CreateVendorDTO>(), It.IsAny<string>()))
|
||||
.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<CreateVendorDTO>(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<ApplicationDbContext>()
|
||||
.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<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
var result = await controller.GetFacets(isActive);
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
|
||||
payload.Should().Contain(expected);
|
||||
payload.Should().NotContain(excluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.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<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
var result = await controller.GetFacets();
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
|
||||
payload.Should().Contain("Active Co");
|
||||
payload.Should().Contain("Inactive Co");
|
||||
}
|
||||
}
|
||||
269
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
269
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
|
|
@ -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<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext NewSqlServerContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.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<Dispatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 100,
|
||||
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
213
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
213
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
|
|
@ -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<IVendorDataService> data) =>
|
||||
new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation());
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_PersistsCompleteProfile()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.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<IVendorDataService>();
|
||||
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<IVendorDataService>();
|
||||
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<IVendorDataService>();
|
||||
data.Setup(x => x.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"chicago",
|
||||
false,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.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<Dispatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 100,
|
||||
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||
{
|
||||
new() { WorkOrderId = 100 },
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
},
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
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<IVendorDataService>();
|
||||
data.Setup(x => x.GetPagedAsync(
|
||||
1,
|
||||
25,
|
||||
"gateway",
|
||||
true,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets))
|
||||
.ReturnsAsync((Enumerable.Empty<Vendor>(), 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -32,16 +32,43 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("GetVendorList")]
|
||||
public async Task<IActionResult> GetVendorList(string? search = "", int page = 1, int pageSize = 10)
|
||||
public async Task<IActionResult> 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<IActionResult> GetVendorById([FromQuery] int? id)
|
||||
public async Task<IActionResult> 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<IActionResult> Delete([FromRoute] int? id, [FromQuery] int? queryId = null)
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> GetPortalToken(int id)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>(
|
||||
name: "CompanyPhone",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleMapsUrl",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1733,6 +1733,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("CompanyName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CompanyPhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ContactName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1748,6 +1751,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("GoogleMapsUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -1760,6 +1766,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Dispatch>? Dispatches { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vendor?> GetByIdAsync(int id)
|
||||
{
|
||||
return await _context.Vendors.FindAsync(id);
|
||||
return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id);
|
||||
}
|
||||
|
||||
public async Task<Vendor?> 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<IEnumerable<Vendor>> GetAllAsync()
|
||||
|
|
@ -32,24 +37,23 @@ namespace SeaHaven.DataServices.Implementation
|
|||
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null)
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? 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<Vendor> BuildDirectoryQuery(
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? 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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<Vendor> ProjectWithTotalJobs(IQueryable<Vendor> 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<Func<Vendor, bool>> BuildTradePredicate(
|
||||
IReadOnlyCollection<string> 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<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
}
|
||||
|
||||
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
|
||||
IReadOnlyCollection<string> 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<Func<Vendor, bool>>(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<Vendor> AddAsync(Vendor vendor)
|
||||
{
|
||||
await _context.Vendors.AddAsync(vendor);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<Vendor?> GetByIdAsync(int id);
|
||||
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null);
|
||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
Task<Vendor> AddAsync(Vendor vendor);
|
||||
Task UpdateAsync(Vendor vendor);
|
||||
Task DeleteAsync(int id);
|
||||
|
|
|
|||
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task<VendorDTO?> 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<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null)
|
||||
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? 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<VendorDTO>
|
||||
{
|
||||
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<int>();
|
||||
return dispatch.WorkOrderId.HasValue
|
||||
? linked.Append(dispatch.WorkOrderId.Value)
|
||||
: linked;
|
||||
})
|
||||
.Distinct()
|
||||
.Count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<VendorDTO?> GetVendorByIdAsync(int id);
|
||||
Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id);
|
||||
Task<IEnumerable<VendorDTO>> GetAllVendorsAsync();
|
||||
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null);
|
||||
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
||||
Task DeleteVendorAsync(int id, string userId);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue