mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
6 commits
8cf54f1757
...
2e5beb232b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5beb232b | ||
|
|
a90e11451e | ||
|
|
5cf7f7da8c | ||
|
|
1a6edd255a | ||
|
|
3a4af64a64 | ||
|
|
67089c2135 |
28 changed files with 21583 additions and 110 deletions
|
|
@ -4,6 +4,7 @@ using Api.SeaHavenIndustries.DTOs;
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
|
@ -25,7 +26,7 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
||||
|
|
@ -34,7 +35,7 @@ public class LocationControllerTests
|
|||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList("a", 1, 10, CancellationToken.None);
|
||||
var result = await NewController(service).GetLocationList("a", 1, 10, cancellationToken: CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||
|
|
@ -44,6 +45,43 @@ public class LocationControllerTests
|
|||
envelope.TotalPages.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_PassesStatesFilterToService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO>(),
|
||||
TotalCount = 0,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
|
||||
}));
|
||||
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Status.Should().Be("Validation Error");
|
||||
response.Message.Should().Contain("ZZ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity()
|
||||
{
|
||||
|
|
|
|||
89
Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs
Normal file
89
Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class LocationDataServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task SeedAsync(ApplicationDbContext ctx)
|
||||
{
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 1, Name = "Alpha Site", City = "Austin", State = "TX" },
|
||||
new Locations { Id = 2, Name = "Beta Site", City = "Dallas", State = "TX" },
|
||||
new Locations { Id = 3, Name = "Gamma Yard", City = "Kansas City", State = "MO" },
|
||||
new Locations { Id = 4, Name = "Delta Hub", City = "Indianapolis", State = "indiana" },
|
||||
new Locations { Id = 5, Name = "Epsilon Depot", City = "Houston", State = "TX" },
|
||||
new Locations { Id = 6, Name = "Zeta Lot", City = "Nowhere", State = null });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_WithoutStates_ReturnsAllRows()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedAsync(ctx);
|
||||
|
||||
var (items, totalCount) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
|
||||
|
||||
totalCount.Should().Be(6);
|
||||
items.Should().HaveCount(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_AppliesStatesFilterBeforeCountAndPaging()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedAsync(ctx);
|
||||
|
||||
var (page1, totalCount) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 2, null, new[] { "TX" }, CancellationToken.None);
|
||||
|
||||
totalCount.Should().Be(3, "count must reflect the state filter, not the whole table");
|
||||
page1.Should().HaveCount(2);
|
||||
page1.Select(l => l.Name).Should().ContainInOrder("Alpha Site", "Beta Site");
|
||||
|
||||
var (page2, totalCountAgain) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(2, 2, null, new[] { "TX" }, CancellationToken.None);
|
||||
|
||||
totalCountAgain.Should().Be(3);
|
||||
page2.Select(l => l.Name).Should().ContainSingle("Epsilon Depot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_MultipleStates_KeepSearchFilter()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedAsync(ctx);
|
||||
|
||||
var (items, totalCount) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, "Dallas", new[] { "TX", "MO" }, CancellationToken.None);
|
||||
|
||||
totalCount.Should().Be(1);
|
||||
items.Should().ContainSingle().Which.Name.Should().Be("Beta Site");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_StatesFilter_ExcludesNullAndNonMatchingStates()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedAsync(ctx);
|
||||
|
||||
var (items, totalCount) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, new[] { "IN", "INDIANA", "MO", "MISSOURI" }, CancellationToken.None);
|
||||
|
||||
totalCount.Should().Be(2);
|
||||
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ public class LocationServiceTests
|
|||
SeedLocation(ctx, "Beta Site", "Dallas");
|
||||
SeedLocation(ctx, "Gamma Yard", "Austin");
|
||||
|
||||
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None);
|
||||
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", cancellationToken: CancellationToken.None);
|
||||
|
||||
page.Items.Should().HaveCount(2);
|
||||
page.TotalCount.Should().Be(2);
|
||||
|
|
@ -126,6 +126,93 @@ public class LocationServiceTests
|
|||
page.Items.Select(l => l.Name).Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_NormalizesStates_CaseWhitespaceAndDuplicates()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
IReadOnlyCollection<string>? captured = default;
|
||||
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
|
||||
|
||||
captured.Should().NotBeNull();
|
||||
captured.Should().BeEquivalentTo(new[] { "TX", "TEXAS", "MO", "MISSOURI" });
|
||||
captured.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_EmptyOrWhitespaceStates_PassesNoStateFilter()
|
||||
{
|
||||
foreach (var states in new[] { null, "", " ", " , , " })
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
IReadOnlyCollection<string>? captured = default;
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
|
||||
|
||||
captured.Should().BeNull($"states input '{states}' must mean no filter");
|
||||
page.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_InvalidStates_ThrowsValidationAndNeverQueries()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e =>
|
||||
e.PropertyName == "states"
|
||||
&& e.ErrorMessage.Contains("ZZ")
|
||||
&& e.ErrorMessage.Contains("QQ")
|
||||
&& !e.ErrorMessage.Contains("TX"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, allStates, CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_AppliesNormalizedStatesThroughDataService()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedLocation(ctx, "Alpha Site", "Austin").State = "TX";
|
||||
SeedLocation(ctx, "Beta Site", "Dallas").State = "TX";
|
||||
SeedLocation(ctx, "Gamma Yard", "Kansas City").State = "MO";
|
||||
SeedLocation(ctx, "Delta Hub", "Indianapolis").State = "IN";
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, " mo , tx ", CancellationToken.None);
|
||||
|
||||
page.TotalCount.Should().Be(3);
|
||||
page.Items.Select(l => l.Name).Should().BeEquivalentTo("Alpha Site", "Beta Site", "Gamma Yard");
|
||||
}
|
||||
|
||||
private static LocationService NewServiceWithSpy(ILocationDataService dataService) =>
|
||||
new(
|
||||
dataService,
|
||||
Mock.Of<IAccountDataService>(),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Observability;
|
||||
using FluentAssertions;
|
||||
|
|
@ -14,6 +15,7 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
public class SentryObservabilityTests
|
||||
{
|
||||
private static readonly string ValidSha = new string('a', 40);
|
||||
private static readonly string UpperSha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelease_Accepts_ServicePrefixed40HexCommit()
|
||||
|
|
@ -23,15 +25,57 @@ public class SentryObservabilityTests
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("1.0.0")]
|
||||
[InlineData("shoc-backend@abc")]
|
||||
[InlineData("shoc-backend@not-a-sha-at-all")]
|
||||
[InlineData("other-service@" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||
[InlineData("other-service@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||
[InlineData("SHOC-BACKEND@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||
[InlineData("")]
|
||||
public void ResolveRelease_FallsBackToLocalDevelopment_ForNonReleaseVersion(string version)
|
||||
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")]
|
||||
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+dirty")]
|
||||
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+")]
|
||||
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa++")]
|
||||
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+extra")]
|
||||
public void ResolveRelease_FallsBackToLocalDevelopment_ForNonReleaseVersion(string? version)
|
||||
{
|
||||
SentryObservability.ResolveRelease(version)
|
||||
.Should().Be(SentryObservability.LocalDevelopmentRelease);
|
||||
SentryObservability.ResolveCommitSha(version)
|
||||
.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelease_Accepts_SdkAppendedMatchingSourceRevisionSuffix()
|
||||
{
|
||||
var informationalVersion = "shoc-backend@" + ValidSha + "+" + ValidSha;
|
||||
|
||||
SentryObservability.ResolveRelease(informationalVersion)
|
||||
.Should().Be("shoc-backend@" + ValidSha);
|
||||
SentryObservability.ResolveCommitSha(informationalVersion)
|
||||
.Should().Be(ValidSha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelease_Accepts_SuffixDifferingOnlyInCase_FromTheCommitSha()
|
||||
{
|
||||
var canonicalSha = UpperSha.ToLowerInvariant();
|
||||
|
||||
SentryObservability.ResolveRelease("shoc-backend@" + UpperSha + "+" + canonicalSha)
|
||||
.Should().Be("shoc-backend@" + canonicalSha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelease_NormalizesUppercaseCommitShaToLowercase()
|
||||
{
|
||||
var canonicalSha = UpperSha.ToLowerInvariant();
|
||||
|
||||
SentryObservability.ResolveRelease("shoc-backend@" + UpperSha)
|
||||
.Should().Be("shoc-backend@" + canonicalSha);
|
||||
SentryObservability.ResolveCommitSha("shoc-backend@" + UpperSha)
|
||||
.Should().Be(canonicalSha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -39,6 +83,28 @@ public class SentryObservabilityTests
|
|||
{
|
||||
var release = SentryObservability.ResolveRelease("shoc-backend@" + ValidSha);
|
||||
release[(SentryObservability.ServiceName.Length + 1)..].Should().Be(ValidSha);
|
||||
SentryObservability.ResolveCommitSha("shoc-backend@" + ValidSha)
|
||||
.Should().Be(ValidSha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelease_AndCommitSha_FromNonReleaseAssembly_FallBackSafely()
|
||||
{
|
||||
var assembly = typeof(SentryObservabilityTests).Assembly;
|
||||
|
||||
SentryObservability.ResolveRelease(assembly)
|
||||
.Should().Be(SentryObservability.LocalDevelopmentRelease);
|
||||
SentryObservability.ResolveCommitSha(assembly)
|
||||
.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelease_AndCommitSha_FromNullAssembly_FallBackSafely()
|
||||
{
|
||||
SentryObservability.ResolveRelease((Assembly?)null)
|
||||
.Should().Be(SentryObservability.LocalDevelopmentRelease);
|
||||
SentryObservability.ResolveCommitSha((Assembly?)null)
|
||||
.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public class VendorControllerTests
|
|||
var service = new Mock<IVendorService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
service.Setup(x => x.GetVendorDirectoryPagedAsync(
|
||||
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
|
|
@ -53,7 +53,7 @@ public class VendorControllerTests
|
|||
|
||||
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
|
||||
|
||||
service.Verify(x => x.GetVendorDirectoryPagedAsync(
|
||||
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
|
|
@ -63,6 +63,175 @@ public class VendorControllerTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token), Times.Once);
|
||||
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||
{
|
||||
Items = new List<VendorDirectoryItemDTO>
|
||||
{
|
||||
new() { Id = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
|
||||
new() { Id = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
|
||||
},
|
||||
TotalCount = 2,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetVendorList();
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||
var rows = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().ToList();
|
||||
|
||||
// Legacy contract: same-company technicians stay as distinct top-level rows
|
||||
// carrying their own vendor ids, and the response shape has no grouped
|
||||
// Technicians property.
|
||||
rows.Should().HaveCount(2);
|
||||
rows.Select(r => r.GetType().GetProperty("Id")!.GetValue(r)).Should().BeEquivalentTo(new[] { 7, 9 });
|
||||
rows.Should().OnlyContain(r => r.GetType().GetProperty("Technicians") == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorList_PreservesLegacyPageSizeAboveGroupedDirectoryBound()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
1,
|
||||
500,
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO> { TotalCount = 420, Page = 1, PageSize = 500 });
|
||||
|
||||
var result = await NewController(service).GetVendorList(page: 1, pageSize: 500);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||
paged.PageSize.Should().Be(500);
|
||||
paged.TotalPages.Should().Be(1);
|
||||
service.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token))
|
||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||
{
|
||||
Items = new List<VendorDirectoryItemDTO>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 9,
|
||||
CompanyId = 3,
|
||||
CompanyName = "Gateway Plumbing",
|
||||
Email = "company@gatewayplumbing.example",
|
||||
TotalJobs = 6,
|
||||
Technicians = new List<VendorDirectoryTechnicianDTO>
|
||||
{
|
||||
new() { Id = 7, ContactName = "Tech A", Email = "tech.a@example.com" },
|
||||
new() { Id = 9, ContactName = "Tech B", Email = "tech.b@example.com" }
|
||||
}
|
||||
}
|
||||
},
|
||||
TotalCount = 1,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetVendorDirectoryList(search: "clayton", cancellationToken: token);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||
var company = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().Single();
|
||||
|
||||
// SH-281: company row with nested technicians; top-level Email stays the
|
||||
// company email while technician emails stay on the nested rows.
|
||||
company.GetType().GetProperty("Email")!.GetValue(company).Should().Be("company@gatewayplumbing.example");
|
||||
var technicians = (System.Collections.IEnumerable)company.GetType().GetProperty("Technicians")!.GetValue(company)!;
|
||||
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Id")!.GetValue(t))
|
||||
.Should().BeEquivalentTo(new[] { 7, 9 });
|
||||
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Email")!.GetValue(t))
|
||||
.Should().BeEquivalentTo(new[] { "tech.a@example.com", "tech.b@example.com" });
|
||||
|
||||
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
10,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token), Times.Once);
|
||||
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes()
|
||||
{
|
||||
var legacyRoutes = typeof(VendorController)
|
||||
.GetMethod(nameof(VendorController.GetVendorList))!
|
||||
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
|
||||
.Cast<HttpGetAttribute>()
|
||||
.Select(attribute => attribute.Template);
|
||||
var companyRoutes = typeof(VendorController)
|
||||
.GetMethod(nameof(VendorController.GetVendorDirectoryList))!
|
||||
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
|
||||
.Cast<HttpGetAttribute>()
|
||||
.Select(attribute => attribute.Template);
|
||||
|
||||
legacyRoutes.Should().BeEquivalentTo("GetVendorList");
|
||||
companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using FluentAssertions;
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
|
@ -327,6 +328,158 @@ public class VendorDataServiceTests
|
|||
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompanyDirectoryPagedAsync_GroupsCompaniesAndKeepsLegacyRowsDistinct()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var linkedCompany = new VendorCompany
|
||||
{
|
||||
Name = "Linked Co",
|
||||
NormalizedName = "linked co",
|
||||
Email = "dispatch@linked.example",
|
||||
CompanyPhone = "555-0100",
|
||||
Address = "100 Company Way",
|
||||
City = "Chicago",
|
||||
State = "IL"
|
||||
};
|
||||
var companyOnly = new VendorCompany { Name = "Company Only", NormalizedName = "company only" };
|
||||
context.VendorCompanies.AddRange(linkedCompany, companyOnly);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor
|
||||
{
|
||||
Company = linkedCompany,
|
||||
CompanyName = "Stale Linked Name",
|
||||
ContactName = "Older Tech",
|
||||
Email = "older@linked.example",
|
||||
Phone = "555-0101",
|
||||
TradeSpecialties = "Plumbing",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 101 } }
|
||||
},
|
||||
new Vendor
|
||||
{
|
||||
Company = linkedCompany,
|
||||
CompanyName = "Another Stale Name",
|
||||
ContactName = "Newer Tech",
|
||||
Email = "newer@linked.example",
|
||||
Phone = "555-0102",
|
||||
TradeSpecialties = "HVAC, plumbing",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 102 } }
|
||||
},
|
||||
new Vendor { CompanyName = " Legacy Co ", ContactName = "Legacy One", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "legacy co", ContactName = "Legacy Two", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = " ", ContactName = "Unnamed One", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = null, ContactName = "Unnamed Two", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var (items, totalCount) = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
|
||||
|
||||
totalCount.Should().Be(5);
|
||||
var linked = items.Single(item => item.CompanyId == linkedCompany.Id);
|
||||
linked.CompanyName.Should().Be("Linked Co");
|
||||
linked.Email.Should().Be("dispatch@linked.example");
|
||||
linked.ContactName.Should().Be("Newer Tech");
|
||||
linked.Technicians.Should().HaveCount(2);
|
||||
linked.TotalJobs.Should().Be(2);
|
||||
linked.TradeSpecialties.Should().Be("HVAC, plumbing");
|
||||
|
||||
var canonicalSearch = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, search: "Linked Co", isActive: true);
|
||||
canonicalSearch.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||
|
||||
var canonicalCompanyFilter = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, isActive: true, companies: new[] { "Linked Co" });
|
||||
canonicalCompanyFilter.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||
|
||||
var legacy = items.Single(item =>
|
||||
item.Technicians.Any(tech => tech.ContactName == "Legacy One"));
|
||||
legacy.Technicians.Select(tech => tech.ContactName)
|
||||
.Should().BeEquivalentTo("Legacy One", "Legacy Two");
|
||||
items.Count(item => item.Technicians.Count == 1 &&
|
||||
item.Technicians.Single().ContactName?.StartsWith("Unnamed") == true).Should().Be(2);
|
||||
items.Single(item => item.CompanyId == companyOnly.Id).Technicians.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompanyDirectoryPagedAsync_FilterSelectsGroupAndSearchPromotesMatchingTechnician()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var company = new VendorCompany { Name = "Search Co", NormalizedName = "search co" };
|
||||
context.VendorCompanies.Add(company);
|
||||
var older = new Vendor
|
||||
{
|
||||
Company = company,
|
||||
CompanyName = company.Name,
|
||||
ContactName = "Alice Plumber",
|
||||
TradeSpecialties = "Plumbing",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 201 } }
|
||||
};
|
||||
var newer = new Vendor
|
||||
{
|
||||
Company = company,
|
||||
CompanyName = company.Name,
|
||||
ContactName = "Zed HVAC",
|
||||
TradeSpecialties = "HVAC",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 202 } }
|
||||
};
|
||||
var inactive = new Vendor
|
||||
{
|
||||
Company = company,
|
||||
CompanyName = company.Name,
|
||||
ContactName = "Inactive Tech",
|
||||
TradeSpecialties = "Roofing",
|
||||
IsActive = false,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch> { new() { WorkOrderId = 203 } }
|
||||
};
|
||||
context.Vendors.AddRange(older, newer, inactive);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var search = await service.GetCompanyDirectoryPagedAsync(1, 10, search: "Alice", isActive: true);
|
||||
var trade = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 10, isActive: true, trades: new[] { "Plumbing" });
|
||||
|
||||
search.TotalCount.Should().Be(1);
|
||||
search.Items.Single().Id.Should().Be(older.Id);
|
||||
search.Items.Single().ContactName.Should().Be("Alice Plumber");
|
||||
search.Items.Single().Technicians.Should().HaveCount(2);
|
||||
search.Items.Single().TotalJobs.Should().Be(2);
|
||||
|
||||
trade.TotalCount.Should().Be(1);
|
||||
trade.Items.Single().Id.Should().Be(newer.Id);
|
||||
trade.Items.Single().Technicians.Should().HaveCount(2);
|
||||
trade.Items.Single().Technicians.Should().NotContain(tech => tech.Id == inactive.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCompanyDirectoryKeysQuery_TranslatesGroupingAndFacetFiltersToSqlServer()
|
||||
{
|
||||
using var context = NewSqlServerContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var sql = service.BuildCompanyDirectoryKeysQuery(
|
||||
search: "alice",
|
||||
trades: new[] { "Plumbing" },
|
||||
locations: new[] { "Chicago, IL" },
|
||||
jobBuckets: new[] { "under-50" }).ToQueryString();
|
||||
|
||||
sql.Should().Contain("GROUP BY");
|
||||
sql.Should().Contain("LEFT JOIN");
|
||||
sql.Should().Contain("TradeSpecialties");
|
||||
sql.Should().Contain("TotalJobs");
|
||||
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
||||
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -152,12 +152,12 @@ public class VendorServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorDirectoryPaged_ForwardsCancellationToken()
|
||||
public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
data.Setup(x => x.GetDirectoryPagedAsync(
|
||||
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"clayton",
|
||||
|
|
@ -167,16 +167,43 @@ public class VendorServiceTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token))
|
||||
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 0));
|
||||
.ReturnsAsync((new[]
|
||||
{
|
||||
new VendorCompanyGroup
|
||||
{
|
||||
Id = 7,
|
||||
CompanyId = 3,
|
||||
CompanyName = "Gateway Plumbing",
|
||||
ContactName = "Primary Tech",
|
||||
Email = "company@gatewayplumbing.example",
|
||||
TotalJobs = 4,
|
||||
Technicians = new[]
|
||||
{
|
||||
new VendorCompanyGroupTechnician
|
||||
{
|
||||
Id = 7,
|
||||
ContactName = "Primary Tech",
|
||||
Email = "primary@example.com",
|
||||
TotalJobs = 4,
|
||||
IsActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}.AsEnumerable().ToList(), 1));
|
||||
|
||||
await NewService(data).GetVendorDirectoryPagedAsync(
|
||||
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"clayton",
|
||||
true,
|
||||
cancellationToken: token);
|
||||
|
||||
data.Verify(x => x.GetDirectoryPagedAsync(
|
||||
var group = result.Items.Single();
|
||||
group.Email.Should().Be("company@gatewayplumbing.example");
|
||||
group.Technicians.Should().ContainSingle()
|
||||
.Which.Email.Should().Be("primary@example.com");
|
||||
|
||||
data.Verify(x => x.GetCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"clayton",
|
||||
|
|
@ -188,6 +215,78 @@ public class VendorServiceTests
|
|||
token), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorCompanyDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||
1,
|
||||
100,
|
||||
null,
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||
|
||||
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(0, 500);
|
||||
|
||||
result.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(100);
|
||||
data.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorTechnicianDirectoryPaged_ForwardsRawPaginationAndCancellationToken()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var token = cancellation.Token;
|
||||
data.Setup(x => x.GetDirectoryPagedAsync(
|
||||
2,
|
||||
500,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token))
|
||||
.ReturnsAsync((new[]
|
||||
{
|
||||
new VendorDirectoryRow { VendorId = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
|
||||
new VendorDirectoryRow { VendorId = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
|
||||
}.AsEnumerable().ToList(), 2));
|
||||
|
||||
var result = await NewService(data).GetVendorTechnicianDirectoryPagedAsync(
|
||||
2,
|
||||
500,
|
||||
"clayton",
|
||||
true,
|
||||
cancellationToken: token);
|
||||
|
||||
// Legacy pagination semantics: page and pageSize (including 500) pass through
|
||||
// unclamped, and same-company technicians stay as distinct rows.
|
||||
result.Page.Should().Be(2);
|
||||
result.PageSize.Should().Be(500);
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items.Select(v => v.Id).Should().BeEquivalentTo(new[] { 7, 9 });
|
||||
result.Items.Should().OnlyContain(v => v.Technicians.Count == 0);
|
||||
|
||||
data.Verify(x => x.GetDirectoryPagedAsync(
|
||||
2,
|
||||
500,
|
||||
"clayton",
|
||||
true,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,38 +36,46 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("GetLocationList")]
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken);
|
||||
|
||||
var data = pagedResult.Items.Select(l => new
|
||||
try
|
||||
{
|
||||
Id = l.Id,
|
||||
Name = l.Name,
|
||||
Title = l.Title,
|
||||
Address = l.Address1,
|
||||
Address1 = l.Address1,
|
||||
Address2 = l.Address2,
|
||||
City = l.City,
|
||||
State = l.State,
|
||||
ZipCode = l.Zip,
|
||||
Phone = l.PhoneNumber,
|
||||
Contact = (string?)null,
|
||||
ContactEmail = l.Email,
|
||||
Status = l.Status,
|
||||
AccountId = l.AccountId
|
||||
});
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||
|
||||
var viewModel = new Pagination_DTO
|
||||
var data = pagedResult.Items.Select(l => new
|
||||
{
|
||||
Id = l.Id,
|
||||
Name = l.Name,
|
||||
Title = l.Title,
|
||||
Address = l.Address1,
|
||||
Address1 = l.Address1,
|
||||
Address2 = l.Address2,
|
||||
City = l.City,
|
||||
State = l.State,
|
||||
ZipCode = l.Zip,
|
||||
Phone = l.PhoneNumber,
|
||||
Contact = (string?)null,
|
||||
ContactEmail = l.Email,
|
||||
Status = l.Status,
|
||||
AccountId = l.AccountId
|
||||
});
|
||||
|
||||
var viewModel = new Pagination_DTO
|
||||
{
|
||||
Data = data,
|
||||
PageNumber = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = pagedResult.TotalCount,
|
||||
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize)
|
||||
};
|
||||
|
||||
return Ok(viewModel);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
{
|
||||
Data = data,
|
||||
PageNumber = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = pagedResult.TotalCount,
|
||||
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize)
|
||||
};
|
||||
|
||||
return Ok(viewModel);
|
||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
// SH-198 legacy per-technician directory contract. Existing consumers depend
|
||||
// on one row per technician (no grouped Technicians) and on raw pagination
|
||||
// semantics, including pageSize values above the grouped-directory bound.
|
||||
[HttpGet("GetVendorList")]
|
||||
public async Task<IActionResult> GetVendorList(
|
||||
string? search = "",
|
||||
|
|
@ -38,7 +41,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[FromQuery] string[]? jobBuckets = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync(
|
||||
var pagedResult = await _vendorService.GetVendorTechnicianDirectoryPagedAsync(
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
|
|
@ -80,6 +83,75 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
});
|
||||
}
|
||||
|
||||
// SH-281 company-grouped directory: one row per company with the full
|
||||
// status-eligible technician roster nested under Technicians and bounded
|
||||
// page sizes. Top-level Email is the company email; technician emails stay
|
||||
// on the nested rows.
|
||||
[HttpGet("GetVendorDirectoryList")]
|
||||
public async Task<IActionResult> GetVendorDirectoryList(
|
||||
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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets,
|
||||
cancellationToken);
|
||||
|
||||
var data = pagedResult.Items.Select(v => new
|
||||
{
|
||||
v.Id,
|
||||
CompanyName = v.CompanyName,
|
||||
v.CompanyId,
|
||||
v.ContactName,
|
||||
v.Email,
|
||||
v.Phone,
|
||||
v.CompanyPhone,
|
||||
v.PreferredContact,
|
||||
v.Address,
|
||||
v.City,
|
||||
v.State,
|
||||
Zip = v.Zipcode,
|
||||
v.TradeSpecialties,
|
||||
v.GoogleMapsUrl,
|
||||
v.Notes,
|
||||
v.IsActive,
|
||||
v.TotalJobs,
|
||||
Technicians = v.Technicians.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.ContactName,
|
||||
t.Email,
|
||||
t.Phone,
|
||||
t.PreferredContact,
|
||||
t.TradeSpecialties,
|
||||
t.IsActive,
|
||||
t.TotalJobs
|
||||
})
|
||||
});
|
||||
|
||||
return Ok(new Pagination_DTO
|
||||
{
|
||||
Data = data,
|
||||
PageNumber = pagedResult.Page,
|
||||
PageSize = pagedResult.PageSize,
|
||||
TotalCount = pagedResult.TotalCount,
|
||||
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pagedResult.PageSize)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("GetById")]
|
||||
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
|
||||
|
|
|
|||
|
|
@ -29,21 +29,46 @@ public static class SentryObservability
|
|||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
|
||||
return IsReleaseInformationalVersion(informationalVersion)
|
||||
? informationalVersion![ReleasePrefix.Length..]
|
||||
: null;
|
||||
return ResolveCommitSha(informationalVersion);
|
||||
}
|
||||
|
||||
public static string ResolveRelease(string? informationalVersion) =>
|
||||
IsReleaseInformationalVersion(informationalVersion)
|
||||
? informationalVersion!
|
||||
TryResolveCommitSha(informationalVersion, out var commitSha)
|
||||
? ReleasePrefix + commitSha
|
||||
: LocalDevelopmentRelease;
|
||||
|
||||
public static string? ResolveCommitSha(string? informationalVersion) =>
|
||||
TryResolveCommitSha(informationalVersion, out var commitSha)
|
||||
? commitSha
|
||||
: null;
|
||||
|
||||
public static bool IsReleaseInformationalVersion(string? value) =>
|
||||
value is not null
|
||||
&& value.StartsWith(ReleasePrefix, StringComparison.Ordinal)
|
||||
&& value.Length == ReleasePrefix.Length + CommitShaLength
|
||||
&& value[ReleasePrefix.Length..].All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F');
|
||||
TryResolveCommitSha(value, out _);
|
||||
|
||||
private static bool TryResolveCommitSha(string? value, out string? commitSha)
|
||||
{
|
||||
commitSha = null;
|
||||
|
||||
if (value is null || !value.StartsWith(ReleasePrefix, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var body = value[ReleasePrefix.Length..];
|
||||
var suffixSeparatorIndex = body.IndexOf('+');
|
||||
var sha = suffixSeparatorIndex < 0 ? body : body[..suffixSeparatorIndex];
|
||||
|
||||
if (sha.Length != CommitShaLength || !sha.All(IsCommitShaHexDigit))
|
||||
return false;
|
||||
|
||||
if (suffixSeparatorIndex >= 0
|
||||
&& !string.Equals(body[(suffixSeparatorIndex + 1)..], sha, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
commitSha = sha.ToLowerInvariant();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsCommitShaHexDigit(char c) =>
|
||||
c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||
|
||||
public static void ConfigureRequest(IHub hub, HttpContext context)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
IReadOnlyCollection<string>? states,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Locations.AsNoTracking();
|
||||
|
|
@ -146,6 +147,11 @@ namespace SeaHaven.DataServices.Implementation
|
|||
l.City!.Contains(search));
|
||||
}
|
||||
|
||||
if (states is { Count: > 0 })
|
||||
{
|
||||
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderBy(l => l.Name)
|
||||
|
|
|
|||
|
|
@ -168,7 +168,188 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
||||
public async Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Phase 1: select and paginate bounded company group keys in SQL.
|
||||
var keyQuery = BuildCompanyDirectoryKeysQuery(
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pageKeys = await keyQuery
|
||||
.OrderByDescending(key => key.MaxVendorId ?? -1)
|
||||
.ThenByDescending(key => key.CompanyId)
|
||||
.ThenBy(key => key.NameKey)
|
||||
.ThenBy(key => key.VendorKey)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (pageKeys.Count == 0)
|
||||
return (Array.Empty<VendorCompanyGroup>(), totalCount);
|
||||
|
||||
// Phase 2: fetch the full status-eligible roster only for the page keys,
|
||||
// without reapplying search/trade/location filters.
|
||||
var rosterRows = await BuildCompanyDirectoryRosterQuery(isActive, pageKeys)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var rosterByKey = rosterRows
|
||||
.GroupBy(row => (CompanyId: row.CompanyId ?? 0, row.NameKey, row.VendorKey))
|
||||
.ToDictionary(group => group.Key, group => group.OrderByDescending(row => row.VendorId).ToList());
|
||||
|
||||
var missingCompanyIds = pageKeys
|
||||
.Where(key => key.CompanyId != 0)
|
||||
.Where(key => !rosterByKey.ContainsKey((key.CompanyId, key.NameKey, key.VendorKey)))
|
||||
.Select(key => key.CompanyId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var companiesById = missingCompanyIds.Count == 0
|
||||
? new Dictionary<int, VendorCompany>()
|
||||
: await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.Where(c => missingCompanyIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
||||
|
||||
// Assemble groups in the page key order so the response preserves the
|
||||
// SQL-selected page order.
|
||||
var groups = new List<VendorCompanyGroup>(pageKeys.Count);
|
||||
foreach (var key in pageKeys)
|
||||
{
|
||||
if (rosterByKey.TryGetValue((key.CompanyId, key.NameKey, key.VendorKey), out var technicians))
|
||||
{
|
||||
groups.Add(BuildTechnicianGroup(technicians, search));
|
||||
}
|
||||
else if (companiesById.TryGetValue(key.CompanyId, out var company))
|
||||
{
|
||||
groups.Add(BuildCompanyOnlyGroup(company));
|
||||
}
|
||||
}
|
||||
|
||||
return (groups, totalCount);
|
||||
}
|
||||
|
||||
private static VendorCompanyGroup BuildTechnicianGroup(
|
||||
IReadOnlyList<VendorDirectoryRosterRow> techniciansNewestFirst,
|
||||
string? search)
|
||||
{
|
||||
var primary = techniciansNewestFirst[0];
|
||||
|
||||
// Primary is the newest VendorId by default; a nonblank search that matches
|
||||
// a non-primary technician's ContactName, email, Phone, or
|
||||
// TradeSpecialties promotes the newest matching technician. Company-level
|
||||
// matches (e.g. company name or location) never reorder.
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var normalizedSearch = search.ToLower();
|
||||
primary = techniciansNewestFirst.FirstOrDefault(row =>
|
||||
((row.ContactName ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||
((row.Email ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||
((row.Phone ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||
((row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch)))
|
||||
?? techniciansNewestFirst[0];
|
||||
}
|
||||
|
||||
var linked = primary.CompanyId != null;
|
||||
var hasCompanyLocation = linked && (
|
||||
!string.IsNullOrWhiteSpace(primary.CompanyCity) ||
|
||||
!string.IsNullOrWhiteSpace(primary.CompanyState) ||
|
||||
!string.IsNullOrWhiteSpace(primary.CompanyAddress));
|
||||
|
||||
return new VendorCompanyGroup
|
||||
{
|
||||
Id = primary.VendorId,
|
||||
CompanyId = primary.CompanyId,
|
||||
CompanyName = linked ? primary.CanonicalCompanyName : primary.CompanyName,
|
||||
ContactName = primary.ContactName,
|
||||
Email = linked ? primary.CompanyEmail : primary.Email,
|
||||
Phone = primary.Phone,
|
||||
CompanyPhone = linked && !string.IsNullOrWhiteSpace(primary.CompanyPhone)
|
||||
? primary.CompanyPhone
|
||||
: primary.VendorCompanyPhone,
|
||||
PreferredContact = primary.PreferredContact,
|
||||
Address = linked && !string.IsNullOrWhiteSpace(primary.CompanyAddress)
|
||||
? primary.CompanyAddress
|
||||
: primary.Address,
|
||||
City = hasCompanyLocation ? primary.CompanyCity : primary.City,
|
||||
State = hasCompanyLocation ? primary.CompanyState : primary.State,
|
||||
Zip = primary.Zip,
|
||||
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
||||
GoogleMapsUrl = primary.GoogleMapsUrl,
|
||||
Notes = linked ? primary.CompanyNotes : null,
|
||||
IsActive = primary.IsActive,
|
||||
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
||||
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
||||
{
|
||||
Id = row.VendorId,
|
||||
ContactName = row.ContactName,
|
||||
Email = row.Email,
|
||||
Phone = row.Phone,
|
||||
PreferredContact = row.PreferredContact,
|
||||
TradeSpecialties = row.TradeSpecialties,
|
||||
IsActive = row.IsActive,
|
||||
TotalJobs = row.TotalJobs
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// Distinct trade CSV tokens case-insensitively, preserving the first displayed
|
||||
// spelling and order (technicians are listed newest VendorId first).
|
||||
private static string? AggregateTradeTokens(IEnumerable<VendorDirectoryRosterRow> techniciansNewestFirst)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var tokens = new List<string>();
|
||||
foreach (var row in techniciansNewestFirst)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.TradeSpecialties))
|
||||
continue;
|
||||
foreach (var token in row.TradeSpecialties.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (seen.Add(token))
|
||||
tokens.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.Count == 0 ? null : string.Join(", ", tokens);
|
||||
}
|
||||
|
||||
private static VendorCompanyGroup BuildCompanyOnlyGroup(VendorCompany company) => new()
|
||||
{
|
||||
Id = null,
|
||||
CompanyId = company.Id,
|
||||
CompanyName = company.Name,
|
||||
ContactName = null,
|
||||
Email = company.Email,
|
||||
Phone = null,
|
||||
CompanyPhone = company.CompanyPhone,
|
||||
PreferredContact = null,
|
||||
Address = company.Address,
|
||||
City = company.City,
|
||||
State = company.State,
|
||||
Zip = company.Zip,
|
||||
TradeSpecialties = null,
|
||||
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||
Notes = company.Notes,
|
||||
IsActive = true,
|
||||
TotalJobs = 0,
|
||||
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
||||
};
|
||||
|
||||
internal IQueryable<VendorCompanyGroupKeyRow> BuildCompanyDirectoryKeysQuery(
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
|
|
@ -176,52 +357,195 @@ namespace SeaHaven.DataServices.Implementation
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null)
|
||||
{
|
||||
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
||||
// those with no company). Notes come from the linked VendorCompany.
|
||||
var perTechnician = _context.Vendors
|
||||
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
||||
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
|
||||
|
||||
var grouped = sourceRows
|
||||
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
||||
.Select(group => new VendorCompanyGroupKeyRow
|
||||
{
|
||||
CompanyId = group.Key.CompanyId,
|
||||
NameKey = group.Key.NameKey,
|
||||
VendorKey = group.Key.VendorKey,
|
||||
MaxVendorId = group.Max(row => row.VendorId),
|
||||
TotalJobs = group.Sum(row => row.TotalJobs),
|
||||
SearchMatch = group.Max(row => row.SearchFlag),
|
||||
CompanyMatch = group.Max(row => row.CompanyFlag),
|
||||
TradeMatch = group.Max(row => row.TradeFlag),
|
||||
LocationMatch = group.Max(row => row.LocationFlag)
|
||||
});
|
||||
|
||||
// Each facet selects the group when any status-eligible row matches it
|
||||
// (OR within a facet, AND across facets); job buckets apply to the summed
|
||||
// per-technician TotalJobs of the whole company.
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
grouped = grouped.Where(key => key.SearchMatch == 1);
|
||||
|
||||
var selectedCompanies = companies?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedCompanies is { Length: > 0 })
|
||||
grouped = grouped.Where(key => key.CompanyMatch == 1);
|
||||
|
||||
var selectedTrades = trades?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedTrades is { Length: > 0 })
|
||||
grouped = grouped.Where(key => key.TradeMatch == 1);
|
||||
|
||||
var selectedLocations = locations?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedLocations is { Length: > 0 })
|
||||
grouped = grouped.Where(key => key.LocationMatch == 1);
|
||||
|
||||
var selectedJobBuckets = jobBuckets?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (selectedJobBuckets is not { Length: > 0 })
|
||||
return grouped;
|
||||
|
||||
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 grouped.Where(key =>
|
||||
(under50 && key.TotalJobs < 50) ||
|
||||
(from50To99 && key.TotalJobs >= 50 && key.TotalJobs <= 99) ||
|
||||
(from100To149 && key.TotalJobs >= 100 && key.TotalJobs <= 149) ||
|
||||
(from150 && key.TotalJobs >= 150));
|
||||
}
|
||||
|
||||
private static Expression<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>> BuildGroupSourceRowSelector(
|
||||
string? search,
|
||||
IReadOnlyCollection<string>? companies,
|
||||
IReadOnlyCollection<string>? trades,
|
||||
IReadOnlyCollection<string>? locations)
|
||||
{
|
||||
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
||||
|
||||
var selectedCompanies = companies?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var selectedTrades = trades?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var selectedLocations = locations?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
static Expression Flag(Expression? matchBody) =>
|
||||
matchBody is null
|
||||
? Expression.Constant(1)
|
||||
: Expression.Condition(matchBody, Expression.Constant(1), Expression.Constant(0));
|
||||
|
||||
var searchBody = string.IsNullOrWhiteSpace(search)
|
||||
? null
|
||||
: BuildSearchMatchBody(row, search.ToLower());
|
||||
var companyBody = selectedCompanies is { Length: > 0 }
|
||||
? BuildCompanyNameMatchBody(row, selectedCompanies)
|
||||
: null;
|
||||
var tradeBody = selectedTrades is { Length: > 0 }
|
||||
? BuildTradeMatchBody<VendorDirectoryRow>(row, selectedTrades)
|
||||
: null;
|
||||
var locationBody = selectedLocations is { Length: > 0 }
|
||||
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
||||
: null;
|
||||
|
||||
var nameKey = Expression.Condition(
|
||||
Expression.NotEqual(
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||
Expression.Constant(0)),
|
||||
Expression.Constant(string.Empty),
|
||||
LowerTrimmed(row, nameof(VendorDirectoryRow.CompanyName)));
|
||||
|
||||
var nameMissing = Expression.Equal(
|
||||
Expression.Call(
|
||||
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||
nameof(string.Trim),
|
||||
Type.EmptyTypes),
|
||||
Expression.Constant(string.Empty));
|
||||
var vendorKey = Expression.Condition(
|
||||
Expression.AndAlso(
|
||||
Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||
Expression.Constant(0)),
|
||||
nameMissing),
|
||||
Expression.Coalesce(
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.VendorId)),
|
||||
Expression.Constant(0)),
|
||||
Expression.Constant(0));
|
||||
|
||||
MemberBinding Bind(string target, Expression value) =>
|
||||
Expression.Bind(typeof(VendorDirectoryGroupSourceRow).GetProperty(target)!, value);
|
||||
|
||||
return Expression.Lambda<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>>(
|
||||
Expression.MemberInit(
|
||||
Expression.New(typeof(VendorDirectoryGroupSourceRow)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.VendorId),
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.VendorId))),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyId),
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId))),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.NameKey), nameKey),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.VendorKey), vendorKey),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.TotalJobs),
|
||||
Expression.Property(row, nameof(VendorDirectoryRow.TotalJobs))),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
|
||||
row);
|
||||
}
|
||||
|
||||
internal IQueryable<VendorDirectoryRosterRow> BuildCompanyDirectoryRosterQuery(
|
||||
bool? isActive,
|
||||
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||
{
|
||||
var vendors = _context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
||||
vendors = vendors.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
var armA = perTechnician.Select(v => new VendorDirectoryRow
|
||||
var rows = vendors.Select(v => new VendorDirectoryRosterRow
|
||||
{
|
||||
VendorId = v.Id,
|
||||
CompanyId = v.CompanyId ?? 0,
|
||||
CompanyName = v.CompanyName,
|
||||
CompanyId = v.CompanyId,
|
||||
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||
CanonicalCompanyName = v.Company != null ? v.Company.Name : null,
|
||||
CompanyEmail = v.Company != null ? v.Company.Email : null,
|
||||
CompanyPhone = v.Company != null ? v.Company.CompanyPhone : null,
|
||||
CompanyAddress = v.Company != null ? v.Company.Address : null,
|
||||
CompanyCity = v.Company != null ? v.Company.City : null,
|
||||
CompanyState = v.Company != null ? v.Company.State : null,
|
||||
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
||||
ContactName = v.ContactName,
|
||||
Email = v.Email != null && v.Email.Trim() != ""
|
||||
? v.Email
|
||||
: v.Company != null
|
||||
? v.Company.Email
|
||||
: v.Email,
|
||||
Email = v.Email,
|
||||
Phone = v.Phone,
|
||||
CompanyPhone = v.Company != null &&
|
||||
v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != ""
|
||||
? v.Company.CompanyPhone
|
||||
: v.CompanyPhone,
|
||||
PreferredContact = v.PreferredContact,
|
||||
Address = v.Company != null &&
|
||||
v.Company.Address != null && v.Company.Address.Trim() != ""
|
||||
? v.Company.Address
|
||||
: v.Address,
|
||||
City = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.City
|
||||
: v.City,
|
||||
State = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.State
|
||||
: v.State,
|
||||
VendorCompanyPhone = v.CompanyPhone,
|
||||
Address = v.Address,
|
||||
City = v.City,
|
||||
State = v.State,
|
||||
Zip = v.Zip,
|
||||
TradeSpecialties = v.TradeSpecialties,
|
||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||
Notes = v.Company != null ? v.Company.Notes : null,
|
||||
IsActive = v.IsActive,
|
||||
TotalJobs = _context.Dispatches
|
||||
.Where(dispatch =>
|
||||
|
|
@ -232,9 +556,156 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context.DispatchWorkOrders
|
||||
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
||||
.Select(link => link.WorkOrderId))
|
||||
.Count()
|
||||
.Count(),
|
||||
NameKey = v.CompanyId != null
|
||||
? ""
|
||||
: (v.CompanyName ?? "").Trim().ToLower(),
|
||||
VendorKey = v.CompanyId == null && (v.CompanyName ?? "").Trim() == ""
|
||||
? v.Id
|
||||
: 0
|
||||
});
|
||||
|
||||
return rows.Where(BuildRosterKeyPredicate(keys));
|
||||
}
|
||||
|
||||
private static Expression<Func<VendorDirectoryRosterRow, bool>> BuildRosterKeyPredicate(
|
||||
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||
{
|
||||
var row = Expression.Parameter(typeof(VendorDirectoryRosterRow), "row");
|
||||
|
||||
Expression? body = null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
Expression keyMatch;
|
||||
if (key.CompanyId != 0)
|
||||
{
|
||||
keyMatch = Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||
Expression.Constant(key.CompanyId, typeof(int?)));
|
||||
}
|
||||
else if (key.NameKey.Length > 0)
|
||||
{
|
||||
keyMatch = Expression.AndAlso(
|
||||
Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||
Expression.Constant(null, typeof(int?))),
|
||||
Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.NameKey)),
|
||||
Expression.Constant(key.NameKey)));
|
||||
}
|
||||
else
|
||||
{
|
||||
keyMatch = Expression.Equal(
|
||||
Expression.Property(row, nameof(VendorDirectoryRosterRow.VendorId)),
|
||||
Expression.Constant(key.VendorKey));
|
||||
}
|
||||
|
||||
body = OrElse(body, keyMatch);
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<VendorDirectoryRosterRow, bool>>(
|
||||
body ?? Expression.Constant(false),
|
||||
row);
|
||||
}
|
||||
|
||||
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null) =>
|
||||
ApplyDirectoryRowFilters(
|
||||
BuildDirectoryUnionBaseQuery(isActive),
|
||||
search,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
private IQueryable<VendorDirectoryRow> BuildDirectoryUnionBaseQuery(bool? isActive = true)
|
||||
{
|
||||
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
||||
// those with no company). Notes come from the linked VendorCompany.
|
||||
var perTechnician = _context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
// Pre-aggregate distinct work-order assignments per technician before
|
||||
// grouping companies. This keeps the outer company SUM over a plain
|
||||
// joined scalar instead of SUM(COUNT(correlated subquery)), which SQL
|
||||
// Server rejects at execution time.
|
||||
var directWorkOrders = _context.Dispatches
|
||||
.Where(dispatch => dispatch.WorkOrderId.HasValue)
|
||||
.Select(dispatch => new
|
||||
{
|
||||
dispatch.VendorId,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value
|
||||
});
|
||||
var linkedWorkOrders = _context.DispatchWorkOrders
|
||||
.Where(link => link.Dispatch != null)
|
||||
.Select(link => new
|
||||
{
|
||||
VendorId = link.Dispatch!.VendorId,
|
||||
link.WorkOrderId
|
||||
});
|
||||
var jobCounts = directWorkOrders
|
||||
.Union(linkedWorkOrders)
|
||||
.GroupBy(assignment => assignment.VendorId)
|
||||
.Select(group => new
|
||||
{
|
||||
VendorId = group.Key,
|
||||
TotalJobs = group.Count()
|
||||
});
|
||||
|
||||
var armA =
|
||||
from v in perTechnician
|
||||
join jobCount in jobCounts on v.Id equals jobCount.VendorId into vendorJobCounts
|
||||
from jobCount in vendorJobCounts.DefaultIfEmpty()
|
||||
select new VendorDirectoryRow
|
||||
{
|
||||
VendorId = v.Id,
|
||||
CompanyId = v.CompanyId ?? 0,
|
||||
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||
ContactName = v.ContactName,
|
||||
Email = v.Email != null && v.Email.Trim() != ""
|
||||
? v.Email
|
||||
: v.Company != null
|
||||
? v.Company.Email
|
||||
: v.Email,
|
||||
Phone = v.Phone,
|
||||
CompanyPhone = v.Company != null &&
|
||||
v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != ""
|
||||
? v.Company.CompanyPhone
|
||||
: v.CompanyPhone,
|
||||
PreferredContact = v.PreferredContact,
|
||||
Address = v.Company != null &&
|
||||
v.Company.Address != null && v.Company.Address.Trim() != ""
|
||||
? v.Company.Address
|
||||
: v.Address,
|
||||
City = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.City
|
||||
: v.City,
|
||||
State = v.Company != null &&
|
||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||
? v.Company.State
|
||||
: v.State,
|
||||
Zip = v.Zip,
|
||||
TradeSpecialties = v.TradeSpecialties,
|
||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||
Notes = v.Company != null ? v.Company.Notes : null,
|
||||
IsActive = v.IsActive,
|
||||
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
||||
};
|
||||
|
||||
// Arm B: company-only rows for companies with zero non-deleted technicians.
|
||||
// These carry a null VendorId and a populated CompanyId. A company has no
|
||||
// active/inactive flag of its own, so it is shown unless the caller explicitly
|
||||
|
|
@ -272,15 +743,70 @@ namespace SeaHaven.DataServices.Implementation
|
|||
union = armA.Concat(armB);
|
||||
}
|
||||
|
||||
return ApplyDirectoryRowFilters(
|
||||
union,
|
||||
search,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
return union;
|
||||
}
|
||||
|
||||
// Same matching rules the directory has always applied to a projected row:
|
||||
// company name, contact name, email, trades, canonical city/state, the
|
||||
// canonical address only when city/state are both absent, and zip.
|
||||
private static Expression BuildSearchMatchBody(ParameterExpression row, string normalizedSearch)
|
||||
{
|
||||
Expression ContainsLower(string propertyName)
|
||||
{
|
||||
var lowered = Expression.Call(
|
||||
CoalescedOrEmpty(row, propertyName),
|
||||
nameof(string.ToLower),
|
||||
Type.EmptyTypes);
|
||||
return Contains(lowered, Expression.Constant(normalizedSearch));
|
||||
}
|
||||
|
||||
var cityBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.City)));
|
||||
var stateBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.State)));
|
||||
|
||||
Expression? body = null;
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.CompanyName)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.ContactName)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Email)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.TradeSpecialties)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.City)));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.State)));
|
||||
body = OrElse(body, Expression.AndAlso(
|
||||
Expression.AndAlso(cityBlank, stateBlank),
|
||||
ContainsLower(nameof(VendorDirectoryRow.Address))));
|
||||
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Zip)));
|
||||
return body!;
|
||||
}
|
||||
|
||||
private static Expression BuildCompanyNameMatchBody(
|
||||
ParameterExpression row,
|
||||
IReadOnlyCollection<string> selectedCompanies)
|
||||
{
|
||||
var lowered = Expression.Call(
|
||||
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||
nameof(string.ToLower),
|
||||
Type.EmptyTypes);
|
||||
return Expression.Call(
|
||||
typeof(Enumerable),
|
||||
nameof(Enumerable.Contains),
|
||||
new[] { typeof(string) },
|
||||
Expression.Constant(selectedCompanies),
|
||||
lowered);
|
||||
}
|
||||
|
||||
private static Expression CoalescedOrEmpty(ParameterExpression row, string propertyName) =>
|
||||
Expression.Coalesce(
|
||||
Expression.Property(row, propertyName),
|
||||
Expression.Constant(string.Empty));
|
||||
|
||||
private static Expression LowerTrimmed(ParameterExpression row, string propertyName) =>
|
||||
Expression.Call(
|
||||
Expression.Call(
|
||||
CoalescedOrEmpty(row, propertyName),
|
||||
nameof(string.Trim),
|
||||
Type.EmptyTypes),
|
||||
nameof(string.ToLower),
|
||||
Type.EmptyTypes);
|
||||
|
||||
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
||||
IQueryable<VendorDirectoryRow> query,
|
||||
string? search,
|
||||
|
|
@ -463,6 +989,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
IReadOnlyCollection<string> selectedTrades)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||
return Expression.Lambda<Func<T, bool>>(
|
||||
BuildTradeMatchBody<T>(vendor, selectedTrades),
|
||||
vendor);
|
||||
}
|
||||
|
||||
private static Expression BuildTradeMatchBody<T>(
|
||||
ParameterExpression vendor,
|
||||
IReadOnlyCollection<string> selectedTrades)
|
||||
{
|
||||
var specialties = Expression.Coalesce(
|
||||
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||
Expression.Constant(string.Empty));
|
||||
|
|
@ -490,13 +1025,22 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
return body ?? Expression.Constant(false);
|
||||
}
|
||||
|
||||
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
||||
IReadOnlyCollection<string> selectedLocations)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||
return Expression.Lambda<Func<T, bool>>(
|
||||
BuildLocationMatchBody<T>(vendor, selectedLocations),
|
||||
vendor);
|
||||
}
|
||||
|
||||
private static Expression BuildLocationMatchBody<T>(
|
||||
ParameterExpression vendor,
|
||||
IReadOnlyCollection<string> selectedLocations)
|
||||
{
|
||||
var label = BuildLocationLabelBody(vendor);
|
||||
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
||||
|
||||
|
|
@ -504,7 +1048,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
foreach (var location in selectedLocations)
|
||||
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
||||
|
||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
return body ?? Expression.Constant(false);
|
||||
}
|
||||
|
||||
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<bool> ExistsAsync(int id);
|
||||
Task<int> CountAsync();
|
||||
|
||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
|
||||
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
|
||||
// SH-198 vendor directory: per-technician rows UNION company-only rows
|
||||
// (companies with zero non-deleted technicians). Company-only rows carry a
|
||||
// null VendorId and a populated CompanyId. Notes come from VendorCompany.Notes.
|
||||
// Compatibility path for existing per-technician consumers (GetVendorList)
|
||||
// and behavior tests. GetVendorDirectoryList uses the company-grouped path
|
||||
// below.
|
||||
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
|
|
@ -48,6 +48,25 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// SH-281 company directory: linked vendors group by real CompanyId; unlinked
|
||||
// legacy vendors group by trimmed case-insensitive CompanyName (missing names
|
||||
// isolated by vendor identity); company-only groups remain. Facet filters
|
||||
// select a group when any status-eligible row matches; job buckets apply to
|
||||
// the summed per-technician TotalJobs. Bounded group keys are selected and
|
||||
// paginated in SQL, then the full status-eligible roster is fetched only for
|
||||
// those keys (search/trade/location are not reapplied). TotalCount counts
|
||||
// company groups.
|
||||
Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Vendor> AddAsync(Vendor vendor);
|
||||
Task UpdateAsync(Vendor vendor);
|
||||
Task DeleteAsync(int id);
|
||||
|
|
|
|||
|
|
@ -20,4 +20,112 @@ namespace SeaHaven.DataServices.Models
|
|||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
}
|
||||
|
||||
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
||||
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
||||
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
||||
// a missing name by vendor identity) plus one 0/1 match flag per facet filter so a
|
||||
// group can be selected when any status-eligible row matches that facet.
|
||||
internal sealed class VendorDirectoryGroupSourceRow
|
||||
{
|
||||
public int? VendorId { get; set; }
|
||||
public int CompanyId { get; set; }
|
||||
public string NameKey { get; set; } = string.Empty;
|
||||
public int VendorKey { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public int SearchFlag { get; set; }
|
||||
public int CompanyFlag { get; set; }
|
||||
public int TradeFlag { get; set; }
|
||||
public int LocationFlag { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
||||
internal sealed class VendorCompanyGroupKeyRow
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string NameKey { get; set; } = string.Empty;
|
||||
public int VendorKey { get; set; }
|
||||
public int? MaxVendorId { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public int SearchMatch { get; set; }
|
||||
public int CompanyMatch { get; set; }
|
||||
public int TradeMatch { get; set; }
|
||||
public int LocationMatch { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
||||
// technician fields and the linked VendorCompany canonical fields, so group
|
||||
// assembly can apply the existing company-canonical projections in memory without
|
||||
// reapplying search/trade/location filters.
|
||||
internal sealed class VendorDirectoryRosterRow
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public string? CanonicalCompanyName { get; set; }
|
||||
public string? CompanyEmail { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? CompanyAddress { get; set; }
|
||||
public string? CompanyCity { get; set; }
|
||||
public string? CompanyState { get; set; }
|
||||
public string? CompanyNotes { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? VendorCompanyPhone { 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 bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public string NameKey { get; set; } = string.Empty;
|
||||
public int VendorKey { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: one technician inside a company group.
|
||||
public sealed class VendorCompanyGroupTechnician
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
}
|
||||
|
||||
// SH-281 company directory group: linked vendors collapse by real CompanyId;
|
||||
// unlinked legacy vendors group by trimmed case-insensitive CompanyName (vendors
|
||||
// with a missing name stay isolated by vendor identity); companies with zero
|
||||
// non-deleted technicians remain as company-only groups. Top-level technician
|
||||
// fields come from the primary technician (newest VendorId, promoted when a
|
||||
// nonblank search matches a non-primary technician); company fields are
|
||||
// company-canonical as the existing projections intend; TotalJobs is the sum of
|
||||
// every status-eligible technician's distinct work-order count.
|
||||
public sealed class VendorCompanyGroup
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? PreferredContact { 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; }
|
||||
public int TotalJobs { get; set; }
|
||||
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,13 @@ namespace SeaHaven.Services.DTOs
|
|||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
// SH-198 vendor directory row. Id is the nullable vendor id (null for company-only
|
||||
// rows that have zero technicians); CompanyId is always populated.
|
||||
// Shared directory row. On the SH-198 legacy per-technician path (GetVendorList)
|
||||
// Id is the technician's own vendor id and Technicians stays empty — the legacy
|
||||
// response shape does not project it. On the SH-281 company-grouped path
|
||||
// (GetVendorDirectoryList) Id is the primary technician's vendor id (null for
|
||||
// company-only groups that have zero technicians); CompanyId is populated for
|
||||
// linked and company-only groups. Technicians carries the full status-eligible
|
||||
// roster of the company group.
|
||||
public class VendorDirectoryItemDTO
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
|
|
@ -42,6 +47,20 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Notes { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
||||
}
|
||||
|
||||
// SH-281: one technician inside a company group; Email/Phone stay technician-owned.
|
||||
public class VendorDirectoryTechnicianDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
}
|
||||
|
||||
public class CreateVendorDTO
|
||||
|
|
|
|||
68
SeaHaven.Services/Helpers/UsStateCodes.cs
Normal file
68
SeaHaven.Services/Helpers/UsStateCodes.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class UsStateCodes
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> Names = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["AL"] = "ALABAMA",
|
||||
["AK"] = "ALASKA",
|
||||
["AZ"] = "ARIZONA",
|
||||
["AR"] = "ARKANSAS",
|
||||
["CA"] = "CALIFORNIA",
|
||||
["CO"] = "COLORADO",
|
||||
["CT"] = "CONNECTICUT",
|
||||
["DE"] = "DELAWARE",
|
||||
["FL"] = "FLORIDA",
|
||||
["GA"] = "GEORGIA",
|
||||
["HI"] = "HAWAII",
|
||||
["ID"] = "IDAHO",
|
||||
["IL"] = "ILLINOIS",
|
||||
["IN"] = "INDIANA",
|
||||
["IA"] = "IOWA",
|
||||
["KS"] = "KANSAS",
|
||||
["KY"] = "KENTUCKY",
|
||||
["LA"] = "LOUISIANA",
|
||||
["ME"] = "MAINE",
|
||||
["MD"] = "MARYLAND",
|
||||
["MA"] = "MASSACHUSETTS",
|
||||
["MI"] = "MICHIGAN",
|
||||
["MN"] = "MINNESOTA",
|
||||
["MS"] = "MISSISSIPPI",
|
||||
["MO"] = "MISSOURI",
|
||||
["MT"] = "MONTANA",
|
||||
["NE"] = "NEBRASKA",
|
||||
["NV"] = "NEVADA",
|
||||
["NH"] = "NEW HAMPSHIRE",
|
||||
["NJ"] = "NEW JERSEY",
|
||||
["NM"] = "NEW MEXICO",
|
||||
["NY"] = "NEW YORK",
|
||||
["NC"] = "NORTH CAROLINA",
|
||||
["ND"] = "NORTH DAKOTA",
|
||||
["OH"] = "OHIO",
|
||||
["OK"] = "OKLAHOMA",
|
||||
["OR"] = "OREGON",
|
||||
["PA"] = "PENNSYLVANIA",
|
||||
["RI"] = "RHODE ISLAND",
|
||||
["SC"] = "SOUTH CAROLINA",
|
||||
["SD"] = "SOUTH DAKOTA",
|
||||
["TN"] = "TENNESSEE",
|
||||
["TX"] = "TEXAS",
|
||||
["UT"] = "UTAH",
|
||||
["VT"] = "VERMONT",
|
||||
["VA"] = "VIRGINIA",
|
||||
["WA"] = "WASHINGTON",
|
||||
["WV"] = "WEST VIRGINIA",
|
||||
["WI"] = "WISCONSIN",
|
||||
["WY"] = "WYOMING"
|
||||
};
|
||||
|
||||
public static IReadOnlySet<string> All { get; } = new HashSet<string>(Names.Keys, StringComparer.Ordinal);
|
||||
|
||||
public static IReadOnlyCollection<string> ExpandStorageValues(IEnumerable<string> codes)
|
||||
{
|
||||
return codes
|
||||
.SelectMany(code => new[] { code, Names[code] })
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,9 +165,12 @@ namespace SeaHaven.Services.Implementation
|
|||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
CancellationToken cancellationToken)
|
||||
string? states = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, cancellationToken);
|
||||
var stateFilter = NormalizeStateFilter(states);
|
||||
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
|
||||
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
|
||||
return new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = items.Select(MapToDTO).ToList(),
|
||||
|
|
@ -177,6 +180,32 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
internal static IReadOnlyCollection<string>? NormalizeStateFilter(string? states)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(states))
|
||||
return null;
|
||||
|
||||
var normalized = states
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(code => code.ToUpperInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
return null;
|
||||
|
||||
var invalid = normalized.Where(code => !UsStateCodes.All.Contains(code)).ToList();
|
||||
if (invalid.Count > 0)
|
||||
{
|
||||
throw new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("states", $"states must be valid US postal abbreviations: {string.Join(", ", invalid)}.")
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public async Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
public class VendorService : IVendorService
|
||||
{
|
||||
private const int MaxDirectoryPageSize = 100;
|
||||
private static readonly string[] TerminalLegacyStatuses =
|
||||
{ "completed", "cancelled", "canceled" };
|
||||
|
||||
|
|
@ -94,7 +95,10 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
||||
// SH-198 legacy per-technician directory contract (GetVendorList): raw
|
||||
// pagination semantics are part of the compatibility surface, so pageSize
|
||||
// is forwarded unclamped.
|
||||
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
|
|
@ -146,6 +150,74 @@ namespace SeaHaven.Services.Implementation
|
|||
TotalJobs = row.TotalJobs
|
||||
};
|
||||
|
||||
// SH-281 company-grouped directory (GetVendorDirectoryList).
|
||||
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
||||
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets,
|
||||
cancellationToken);
|
||||
|
||||
return new PagedResult<VendorDirectoryItemDTO>
|
||||
{
|
||||
Items = items.Select(MapToCompanyGroup).ToList(),
|
||||
TotalCount = totalCount,
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize
|
||||
};
|
||||
}
|
||||
|
||||
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
||||
{
|
||||
Id = group.Id,
|
||||
CompanyId = group.CompanyId,
|
||||
CompanyName = group.CompanyName,
|
||||
ContactName = group.ContactName,
|
||||
Email = group.Email,
|
||||
Phone = group.Phone,
|
||||
CompanyPhone = group.CompanyPhone,
|
||||
PreferredContact = group.PreferredContact,
|
||||
Address = group.Address,
|
||||
City = group.City,
|
||||
State = group.State,
|
||||
Zipcode = group.Zip,
|
||||
TradeSpecialties = group.TradeSpecialties,
|
||||
GoogleMapsUrl = group.GoogleMapsUrl,
|
||||
Notes = group.Notes,
|
||||
IsActive = group.IsActive,
|
||||
TotalJobs = group.TotalJobs,
|
||||
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
||||
};
|
||||
|
||||
private static VendorDirectoryTechnicianDTO MapToTechnician(VendorCompanyGroupTechnician technician) => new()
|
||||
{
|
||||
Id = technician.Id,
|
||||
ContactName = technician.ContactName,
|
||||
Email = technician.Email,
|
||||
Phone = technician.Phone,
|
||||
PreferredContact = technician.PreferredContact,
|
||||
TradeSpecialties = technician.TradeSpecialties,
|
||||
IsActive = technician.IsActive,
|
||||
TotalJobs = technician.TotalJobs
|
||||
};
|
||||
|
||||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||||
{
|
||||
NormalizePhoneFields(dto);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<bool> LocationExistsAsync(int id);
|
||||
Task<int> GetTotalLocationCountAsync();
|
||||
|
||||
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
||||
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default);
|
||||
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
||||
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -17,8 +17,22 @@ namespace SeaHaven.Services.Interfaces
|
|||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
|
||||
// SH-198 vendor directory: per-technician rows UNION company-only rows.
|
||||
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
||||
// SH-198 legacy vendor directory contract backing GetVendorList: one row per
|
||||
// technician (per-technician rows UNION company-only rows), raw pagination.
|
||||
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
|
||||
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,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// SH-281 company directory backing GetVendorDirectoryList: company-grouped
|
||||
// rows with nested technicians and bounded page sizes.
|
||||
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
|
|
|
|||
|
|
@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests
|
|||
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
||||
public Task<int> CountAsync() => _inner.CountAsync();
|
||||
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||
int page, int pageSize, string? search, CancellationToken cancellationToken)
|
||||
=> _inner.GetListPagedAsync(page, pageSize, search, cancellationToken);
|
||||
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken)
|
||||
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
||||
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -14,11 +14,14 @@ route values, query strings, or identity claims.
|
|||
## Decision
|
||||
|
||||
1. **Release identity.** `SentryObservability.ResolveRelease(Assembly)` accepts an
|
||||
informational version shaped `shoc-backend@<40 hex commit sha>` and otherwise
|
||||
resolves to `local-development`. `scripts/package-elastic-beanstalk.sh` resolves
|
||||
informational version shaped `shoc-backend@<40 hex commit sha>`, tolerates the
|
||||
.NET SDK's matching `+<same sha>` suffix for compatibility, canonicalizes the
|
||||
result to lowercase, and otherwise resolves to `local-development`.
|
||||
`scripts/package-elastic-beanstalk.sh` resolves
|
||||
the commit from `APP_COMMIT_SHA` → `GITHUB_SHA` → `git rev-parse HEAD`, fails
|
||||
when `CI=true` cannot produce a valid 40-hex sha, and publishes with
|
||||
`-p:SourceRevisionId`, `-p:InformationalVersion=shoc-backend@<sha>`,
|
||||
`-p:IncludeSourceRevisionInInformationalVersion=false`,
|
||||
`-p:DebugType=portable`, `-p:DebugSymbols=true`.
|
||||
2. **Default tags.** `service=shoc-backend` and `app.commit=<sha | local-development>`
|
||||
on `SentryOptions.DefaultTags`. All events use the same commit-addressed
|
||||
|
|
|
|||
65
postman/README.md
Normal file
65
postman/README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# SeaHaven Dev Postman collection
|
||||
|
||||
This directory contains the complete SeaHaven backend **dev** API collection,
|
||||
generated from the live OpenAPI contract. The generated collection currently
|
||||
contains every operation in the contract (368 operations across 330 paths),
|
||||
including all documented parameters, request bodies, schemas, and response
|
||||
codes.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `SeaHaven-Dev.postman_collection.json` | Complete OpenAPI-derived Postman v2.1 collection, grouped by API tag |
|
||||
| `SeaHaven-Dev.postman_environment.json` | Dev authentication environment with secret username, password, and reusable bearer token |
|
||||
| `../scripts/generate-postman-collection.mjs` | Deterministic generator and coverage checker |
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Import `SeaHaven-Dev.postman_collection.json` into Postman.
|
||||
2. Import `SeaHaven-Dev.postman_environment.json` and select **SeaHaven - Dev**.
|
||||
3. Enter the dev test account's `username` and `password` in the environment.
|
||||
They are empty and secret by default; never commit or share populated values.
|
||||
4. Run **Setup → Authentication - Login**. The pre-request script clears any
|
||||
stale token, skips the request when either credential is missing, and never
|
||||
logs credentials. A successful response stores only its non-empty `token`
|
||||
field in the environment's `token` variable.
|
||||
5. All generated API requests inherit collection-level `Bearer {{token}}`
|
||||
authentication. They reuse the captured token automatically.
|
||||
|
||||
## Generated requests and examples
|
||||
|
||||
Every OpenAPI operation is included under its first alphabetically sorted tag.
|
||||
Path, query, and header parameters retain their required/optional status,
|
||||
schema hint, and description. JSON request bodies use recursively generated
|
||||
illustrative placeholders from local OpenAPI schemas; multipart requests use
|
||||
form-data fields and mark binary fields as files. These examples are not live
|
||||
records: replace placeholder values with a valid dev fixture before sending.
|
||||
No response bodies, credentials, tokens, or other dev data are stored in this
|
||||
repository.
|
||||
|
||||
The generated test script only checks that the response status is one of the
|
||||
codes documented by OpenAPI. It does not assume every operation returns `200`.
|
||||
|
||||
## Regenerate or check coverage
|
||||
|
||||
From the backend repository root:
|
||||
|
||||
```bash
|
||||
node scripts/generate-postman-collection.mjs
|
||||
node scripts/generate-postman-collection.mjs --check
|
||||
```
|
||||
|
||||
The generator fetches `https://api.dev.seahaven.com/swagger/v1/swagger.json`,
|
||||
fails on a non-JSON/non-success response, and rewrites the collection and the
|
||||
empty-credential environment deterministically. `--check` compares the checked
|
||||
in collection with the current contract and verifies operation count, unique
|
||||
operation keys, request-body coverage, Bearer inheritance, and the explicit
|
||||
no-auth login exception.
|
||||
|
||||
## Safety
|
||||
|
||||
This collection is **dev-only**. Do not point it at staging or production. Keep
|
||||
`username`, `password`, and `token` local to your Postman workspace; never mark
|
||||
them shared, export them, or commit them. Review every illustrative body and
|
||||
parameter before running a mutating request.
|
||||
19314
postman/SeaHaven-Dev.postman_collection.json
Normal file
19314
postman/SeaHaven-Dev.postman_collection.json
Normal file
File diff suppressed because it is too large
Load diff
30
postman/SeaHaven-Dev.postman_environment.json
Normal file
30
postman/SeaHaven-Dev.postman_environment.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "SeaHaven - Dev",
|
||||
"values": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "https://api.dev.seahaven.com",
|
||||
"type": "default",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "username",
|
||||
"value": "",
|
||||
"type": "secret",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "password",
|
||||
"value": "",
|
||||
"type": "secret",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "token",
|
||||
"value": "",
|
||||
"type": "secret",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"_postman_variable_scope": "environment"
|
||||
}
|
||||
375
scripts/generate-postman-collection.mjs
Normal file
375
scripts/generate-postman-collection.mjs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULT_SOURCE = "https://api.dev.seahaven.com/swagger/v1/swagger.json";
|
||||
const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
|
||||
const COLLECTION_PATH = path.join(ROOT, "postman", "SeaHaven-Dev.postman_collection.json");
|
||||
const ENVIRONMENT_PATH = path.join(ROOT, "postman", "SeaHaven-Dev.postman_environment.json");
|
||||
const METHODS = ["get", "post", "put", "patch", "delete", "options", "head", "trace"];
|
||||
|
||||
const sourceArg = process.argv.find((arg) => arg.startsWith("--source="));
|
||||
const inputArg = process.argv.find((arg) => arg.startsWith("--input="));
|
||||
const sourceUrl = sourceArg ? sourceArg.slice("--source=".length) : DEFAULT_SOURCE;
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
function operationEntries(document) {
|
||||
return Object.entries(document.paths ?? {}).flatMap(([route, pathItem]) =>
|
||||
METHODS.filter((method) => pathItem?.[method]).map((method) => ({
|
||||
route,
|
||||
method,
|
||||
operation: pathItem[method],
|
||||
pathParameters: pathItem.parameters ?? [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function schemaRefName(ref) {
|
||||
return typeof ref === "string" ? ref.split("/").pop() : undefined;
|
||||
}
|
||||
|
||||
function resolveSchema(schema, document) {
|
||||
if (!schema) return undefined;
|
||||
if (schema.$ref) return document.components?.schemas?.[schemaRefName(schema.$ref)] ?? schema;
|
||||
return schema;
|
||||
}
|
||||
|
||||
function safeSample(schema, document, name = "value", depth = 0, seen = new Set()) {
|
||||
const resolved = resolveSchema(schema, document) ?? {};
|
||||
if (depth > 4) return "<nested value>";
|
||||
if (resolved.$ref) {
|
||||
if (seen.has(resolved.$ref)) return "<recursive value>";
|
||||
seen.add(resolved.$ref);
|
||||
}
|
||||
if (resolved.enum?.length) return resolved.enum[0];
|
||||
if (resolved.oneOf?.length) return safeSample(resolved.oneOf[0], document, name, depth + 1, seen);
|
||||
if (resolved.anyOf?.length) return safeSample(resolved.anyOf[0], document, name, depth + 1, seen);
|
||||
if (resolved.allOf?.length) {
|
||||
return resolved.allOf.reduce((acc, part) => {
|
||||
const value = safeSample(part, document, name, depth + 1, seen);
|
||||
return typeof value === "object" && value && !Array.isArray(value) ? { ...acc, ...value } : value;
|
||||
}, {});
|
||||
}
|
||||
if (resolved.type === "array" || resolved.items) {
|
||||
return [safeSample(resolved.items ?? {}, document, `${name}Item`, depth + 1, seen)];
|
||||
}
|
||||
if (resolved.type === "object" || resolved.properties || resolved.additionalProperties) {
|
||||
const object = {};
|
||||
for (const [property, propertySchema] of Object.entries(resolved.properties ?? {})) {
|
||||
object[property] = safeSample(propertySchema, document, property, depth + 1, seen);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
switch (resolved.format) {
|
||||
case "date-time": return "2026-01-01T00:00:00Z";
|
||||
case "date": return "2026-01-01";
|
||||
case "uuid": return "00000000-0000-0000-0000-000000000000";
|
||||
case "email": return "user@example.com";
|
||||
case "uri": return "https://example.com/resource";
|
||||
case "binary": return "<binary file>";
|
||||
default: break;
|
||||
}
|
||||
switch (resolved.type) {
|
||||
case "integer":
|
||||
case "number": return 0;
|
||||
case "boolean": return false;
|
||||
case "null": return null;
|
||||
default: return "<string>";
|
||||
}
|
||||
}
|
||||
|
||||
function schemaHint(schema) {
|
||||
if (!schema) return "schema unspecified";
|
||||
if (schema.$ref) return schema.$ref;
|
||||
const bits = [schema.type, schema.format].filter(Boolean);
|
||||
if (schema.enum?.length) bits.push(`enum: ${schema.enum.join(", ")}`);
|
||||
return bits.join("/") || "schema";
|
||||
}
|
||||
|
||||
function parameterValue(parameter, document) {
|
||||
const schema = parameter.schema ?? {};
|
||||
if (schema.enum?.length) return String(schema.enum[0]);
|
||||
if (parameter.example !== undefined) return String(parameter.example);
|
||||
if (schema.type === "boolean") return "false";
|
||||
if (["integer", "number"].includes(schema.type)) return "0";
|
||||
if (schema.type === "array") return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
function mergeParameters(entry) {
|
||||
const parameters = [...entry.pathParameters, ...(entry.operation.parameters ?? [])];
|
||||
const seen = new Set();
|
||||
return parameters.filter((parameter) => {
|
||||
const key = `${parameter.in}:${parameter.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return ["path", "query", "header"].includes(parameter.in);
|
||||
});
|
||||
}
|
||||
|
||||
function urlFor(entry, parameters) {
|
||||
const pathParameters = parameters.filter((p) => p.in === "path");
|
||||
const queryParameters = parameters.filter((p) => p.in === "query");
|
||||
const headerParameters = parameters.filter((p) => p.in === "header");
|
||||
const postmanPath = entry.route.replaceAll(/\{([^}]+)\}/g, ":$1");
|
||||
const query = queryParameters.map((p) => ({
|
||||
key: p.name,
|
||||
value: parameterValue(p),
|
||||
description: `${p.required ? "Required" : "Optional"}; ${schemaHint(p.schema)}`,
|
||||
disabled: false,
|
||||
}));
|
||||
const raw = `{{baseUrl}}${postmanPath}${query.length ? `?${query.map((p) => `${p.key}=${p.value}`).join("&")}` : ""}`;
|
||||
return {
|
||||
raw,
|
||||
host: ["{{baseUrl}}"],
|
||||
path: postmanPath.split("/").filter(Boolean),
|
||||
...(query.length ? { query } : {}),
|
||||
...(pathParameters.length ? {
|
||||
variable: pathParameters.map((p) => ({
|
||||
key: p.name,
|
||||
value: parameterValue(p),
|
||||
description: `${p.required ? "Required" : "Optional"}; ${schemaHint(p.schema)}`,
|
||||
})),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function bodyFor(entry, document) {
|
||||
const content = entry.operation.requestBody?.content ?? {};
|
||||
const mediaTypes = Object.keys(content);
|
||||
const jsonType = mediaTypes.find((type) => type === "application/json" || type === "text/json" || type.includes("+json"));
|
||||
const type = jsonType ?? mediaTypes.find((value) => value === "multipart/form-data") ?? mediaTypes[0];
|
||||
if (!type) return undefined;
|
||||
const schema = content[type]?.schema;
|
||||
const description = `Illustrative placeholder generated from ${schemaHint(schema)}; replace placeholder values before sending.`;
|
||||
if (type === "multipart/form-data") {
|
||||
const resolved = resolveSchema(schema, document) ?? {};
|
||||
const formdata = Object.entries(resolved.properties ?? {}).map(([key, propertySchema]) => ({
|
||||
key,
|
||||
type: propertySchema.format === "binary" ? "file" : "text",
|
||||
...(propertySchema.format === "binary" ? { src: "" } : { value: String(safeSample(propertySchema, document, key)) }),
|
||||
description: `${resolved.required?.includes(key) ? "Required" : "Optional"}; ${schemaHint(propertySchema)}`,
|
||||
}));
|
||||
return { mode: "formdata", formdata, description };
|
||||
}
|
||||
if (type === "application/octet-stream") return { mode: "file", file: { src: "" }, description };
|
||||
if (type.startsWith("text/")) return { mode: "raw", raw: "<text payload>", options: { raw: { language: "text" } }, description };
|
||||
return { mode: "raw", raw: JSON.stringify(safeSample(schema, document), null, 2), options: { raw: { language: "json" } }, description };
|
||||
}
|
||||
|
||||
function operationDescription(entry, document, parameters, body) {
|
||||
const operation = entry.operation;
|
||||
const responses = Object.entries(operation.responses ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, response]) => {
|
||||
const mediaTypes = Object.keys(response.content ?? {});
|
||||
return `${code}${mediaTypes.length ? ` (${mediaTypes.join(", ")})` : ""}`;
|
||||
})
|
||||
.join(", ") || "not documented";
|
||||
const requestMediaTypes = Object.keys(operation.requestBody?.content ?? {});
|
||||
const lines = [
|
||||
`Source: ${sourceUrl}`,
|
||||
`Tag(s): ${(operation.tags ?? ["Other"]).join(", ")}`,
|
||||
`Operation ID: ${operation.operationId ?? "not specified"}`,
|
||||
"",
|
||||
operation.summary ?? "",
|
||||
operation.description ?? "",
|
||||
"",
|
||||
`Parameters: ${parameters.length ? parameters.map((p) => `${p.in} ${p.name} (${p.required ? "required" : "optional"}, ${schemaHint(p.schema)})`).join("; ") : "none"}`,
|
||||
`Request body: ${body ? `${requestMediaTypes.join(", ") || "media type unspecified"}; ${body.description}` : "none documented"}`,
|
||||
`Documented response codes: ${responses}`,
|
||||
"",
|
||||
"Body values and parameter values are safe illustrative placeholders; review them against the target record before sending.",
|
||||
];
|
||||
return lines.filter((line, index) => !(line === "" && lines[index - 1] === "")).join("\n").trim();
|
||||
}
|
||||
|
||||
function testEvent(operation) {
|
||||
const codes = Object.keys(operation.responses ?? {}).filter((key) => /^\d{3}$/.test(key)).map(Number);
|
||||
if (!codes.length) return [];
|
||||
return [{
|
||||
listen: "test",
|
||||
script: {
|
||||
type: "text/javascript",
|
||||
exec: [
|
||||
`const documented = [${codes.join(", ")}];`,
|
||||
"pm.test(\"Response status is documented by OpenAPI\", function () {",
|
||||
" pm.expect(documented).to.include(pm.response.code);",
|
||||
"});",
|
||||
],
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
function requestItem(entry, document) {
|
||||
const parameters = mergeParameters(entry);
|
||||
const body = bodyFor(entry, document);
|
||||
const headers = parameters.filter((p) => p.in === "header").map((p) => ({
|
||||
key: p.name,
|
||||
value: parameterValue(p),
|
||||
description: `${p.required ? "Required" : "Optional"}; ${schemaHint(p.schema)}`,
|
||||
}));
|
||||
if (body?.mode === "raw" && body.options?.raw?.language === "json") headers.push({ key: "Content-Type", value: "application/json" });
|
||||
if (body?.mode === "raw" && body.options?.raw?.language === "text") headers.push({ key: "Content-Type", value: "text/plain" });
|
||||
const item = {
|
||||
name: `${entry.method.toUpperCase()} ${entry.route}`,
|
||||
request: {
|
||||
method: entry.method.toUpperCase(),
|
||||
header: headers,
|
||||
url: urlFor(entry, parameters),
|
||||
description: operationDescription(entry, document, parameters, body),
|
||||
...(body ? { body } : {}),
|
||||
},
|
||||
event: testEvent(entry.operation),
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
function setupItems(document) {
|
||||
const login = operationEntries(document).find((entry) => entry.route.toLowerCase() === "/api/authentication/login" && entry.method === "post");
|
||||
const loginSchema = login?.operation.requestBody?.content?.["application/json"]?.schema;
|
||||
return [
|
||||
{
|
||||
name: "Swagger - OpenAPI document",
|
||||
request: {
|
||||
auth: { type: "noauth" }, method: "GET", header: [],
|
||||
url: { raw: "{{baseUrl}}/swagger/v1/swagger.json", host: ["{{baseUrl}}"], path: ["swagger", "v1", "swagger.json"] },
|
||||
description: `Live OpenAPI contract for dev (${sourceUrl}). Import this URL when you need the latest server surface. No auth required.`,
|
||||
},
|
||||
event: [{ listen: "test", script: { type: "text/javascript", exec: [
|
||||
"pm.test(\"OpenAPI document responded 200\", function () { pm.response.to.have.status(200); });",
|
||||
"pm.test(\"OpenAPI document is JSON\", function () { pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\"); });",
|
||||
"pm.test(\"OpenAPI document has paths\", function () { pm.expect(pm.response.json()).to.have.property(\"paths\"); });",
|
||||
] } }],
|
||||
},
|
||||
{
|
||||
name: "Authentication - Login",
|
||||
request: {
|
||||
auth: { type: "noauth" }, method: "POST", header: [{ key: "Content-Type", value: "application/json" }],
|
||||
body: { mode: "raw", raw: JSON.stringify({ username: "{{username}}", password: "{{password}}" }, null, 2), options: { raw: { language: "json" } }, description: "Credentials are read from the selected secret environment and are never logged." },
|
||||
url: { raw: "{{baseUrl}}/api/Authentication/login", host: ["{{baseUrl}}"], path: ["api", "Authentication", "login"] },
|
||||
description: `Authentication login from ${sourceUrl}. A successful response stores only the non-empty token in the selected environment. Schema: ${schemaHint(loginSchema)}.`,
|
||||
},
|
||||
event: [
|
||||
{ listen: "prerequest", script: { type: "text/javascript", exec: [
|
||||
"pm.environment.set(\"token\", \"\");",
|
||||
"const username = pm.environment.get(\"username\");",
|
||||
"const password = pm.environment.get(\"password\");",
|
||||
"if (!username || !password) {",
|
||||
" pm.execution.skipRequest();",
|
||||
" throw new Error(\"Select the SeaHaven - Dev environment and set username and password before logging in.\");",
|
||||
"}",
|
||||
] } },
|
||||
{ listen: "test", script: { type: "text/javascript", exec: [
|
||||
"pm.test(\"Login responded 200\", function () { pm.response.to.have.status(200); });",
|
||||
"let body;",
|
||||
"pm.test(\"Login responded with JSON\", function () { pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\"); body = pm.response.json(); });",
|
||||
"const token = body && body.token;",
|
||||
"pm.test(\"Login response contained a non-empty token\", function () { pm.expect(token, \"expected a non-empty token field\").to.be.a(\"string\").and.not.empty; });",
|
||||
"if (typeof token === \"string\" && token.length > 0) pm.environment.set(\"token\", token);",
|
||||
] } },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildCollection(document) {
|
||||
const entries = operationEntries(document);
|
||||
const loginEntry = entries.find((entry) => entry.route.toLowerCase() === "/api/authentication/login" && entry.method === "post");
|
||||
const folders = new Map();
|
||||
for (const entry of entries) {
|
||||
if (entry === loginEntry) continue;
|
||||
const tag = [...(entry.operation.tags ?? ["Other"])].sort()[0] ?? "Other";
|
||||
if (!folders.has(tag)) folders.set(tag, []);
|
||||
folders.get(tag).push(requestItem(entry, document));
|
||||
}
|
||||
const generatedFolders = [...folders.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, items]) => ({
|
||||
name,
|
||||
item: items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
return {
|
||||
info: {
|
||||
name: "SeaHaven - Dev API (OpenAPI generated)",
|
||||
_postman_id: "a1f0514a-5e9f-41bd-a145-7163671b94db",
|
||||
description: `Complete Postman collection generated from ${sourceUrl}. It contains every OpenAPI operation in the published dev contract. Dev only. Replace illustrative placeholders before sending requests.`,
|
||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
auth: { type: "bearer", bearer: [{ key: "token", value: "{{token}}", type: "string" }] },
|
||||
variable: [{ key: "baseUrl", value: "https://api.dev.seahaven.com", type: "string" }],
|
||||
item: [{ name: "Setup", item: setupItems(document) }, ...generatedFolders],
|
||||
};
|
||||
}
|
||||
|
||||
function flattenCollectionItems(items, folder = "") {
|
||||
return items.flatMap((item) => item.item ? flattenCollectionItems(item.item, `${folder}${item.name}/`) : [{ ...item, folder }]);
|
||||
}
|
||||
|
||||
function validate(document, collection) {
|
||||
const sourceEntries = operationEntries(document);
|
||||
const allItems = flattenCollectionItems(collection.item);
|
||||
const generated = allItems.filter((item) => !item.folder.startsWith("Setup/"));
|
||||
const setupLogin = allItems.find((item) => item.name === "Authentication - Login");
|
||||
const sourceKeys = new Set(sourceEntries.map((entry) => `${entry.method.toUpperCase()} ${entry.route}`));
|
||||
const requestKey = (item) => `${item.request.method} ${item.request.url.raw.replace("{{baseUrl}}", "").replace(/\?.*$/, "").replaceAll(/:([^/]+)/g, "{$1}")}`;
|
||||
const representedItems = [...generated, setupLogin].filter(Boolean);
|
||||
const representedKeys = new Set(representedItems.map(requestKey));
|
||||
const bodyCount = sourceEntries.filter((entry) => entry.operation.requestBody).length;
|
||||
const generatedBodyCount = representedItems.filter((item) => item.request.body).length;
|
||||
const authFailures = generated.filter((item) => item.request.auth?.type === "noauth");
|
||||
const authConfig = collection.auth?.type === "bearer" && collection.auth.bearer?.some((entry) => entry.key === "token" && entry.value === "{{token}}");
|
||||
const missingKeys = [...sourceKeys].filter((key) => !representedKeys.has(key));
|
||||
const extraKeys = [...representedKeys].filter((key) => !sourceKeys.has(key));
|
||||
const checks = [
|
||||
["operation count", representedKeys.size, sourceEntries.length],
|
||||
["unique operation keys", missingKeys.length + extraKeys.length, 0],
|
||||
["request-body coverage", generatedBodyCount, bodyCount],
|
||||
["protected operations inherit collection auth", authFailures.length, 0],
|
||||
["collection bearer token auth", authConfig, true],
|
||||
["login is explicitly noauth", setupLogin?.request?.auth?.type, "noauth"],
|
||||
];
|
||||
const failures = checks.filter(([, actual, expected]) => actual !== expected);
|
||||
if (failures.length) {
|
||||
for (const [label, actual, expected] of failures) console.error(`FAIL ${label}: expected ${expected}, observed ${actual}`);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
for (const [label, actual, expected] of checks) console.log(`PASS ${label}: ${actual}`);
|
||||
if (missingKeys.length || extraKeys.length) {
|
||||
console.error(`Operation key mismatch. Missing: ${missingKeys.join(", ")}; extra: ${extraKeys.join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadDocument() {
|
||||
if (inputArg) return JSON.parse(await fs.readFile(inputArg.slice("--input=".length), "utf8"));
|
||||
const response = await fetch(sourceUrl, { headers: { accept: "application/json" } });
|
||||
if (!response.ok) throw new Error(`OpenAPI fetch failed: HTTP ${response.status}`);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("json")) throw new Error(`OpenAPI fetch returned non-JSON content type: ${contentType}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const document = await loadDocument();
|
||||
const collection = buildCollection(document);
|
||||
if (checkOnly) {
|
||||
const existing = JSON.parse(await fs.readFile(COLLECTION_PATH, "utf8"));
|
||||
validate(document, existing);
|
||||
} else {
|
||||
await fs.writeFile(COLLECTION_PATH, `${JSON.stringify(collection, null, 2)}\n`);
|
||||
await fs.writeFile(ENVIRONMENT_PATH, `${JSON.stringify({
|
||||
name: "SeaHaven - Dev",
|
||||
values: [
|
||||
{ key: "baseUrl", value: "https://api.dev.seahaven.com", type: "default", enabled: true },
|
||||
{ key: "username", value: "", type: "secret", enabled: true },
|
||||
{ key: "password", value: "", type: "secret", enabled: true },
|
||||
{ key: "token", value: "", type: "secret", enabled: true },
|
||||
],
|
||||
_postman_variable_scope: "environment",
|
||||
}, null, 2)}\n`);
|
||||
console.log(`Generated ${operationEntries(document).length} operations at ${COLLECTION_PATH}`);
|
||||
validate(document, collection);
|
||||
}
|
||||
|
|
@ -112,6 +112,7 @@ log "publish $API_PROJECT (Release, self-contained, $RUNTIME)"
|
|||
-p:UseAppHost=true \
|
||||
-p:SourceRevisionId="$COMMIT_SHA" \
|
||||
-p:InformationalVersion="shoc-backend@$COMMIT_SHA" \
|
||||
-p:IncludeSourceRevisionInInformationalVersion=false \
|
||||
-p:DebugType=portable \
|
||||
-p:DebugSymbols=true
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue