Compare commits

...

6 commits

Author SHA1 Message Date
Alexandre Brandizzi
2e5beb232b
Merge a90e11451e into 1a6edd255a 2026-09-15 19:32:46 +00:00
Alexandre Brandizzi
a90e11451e docs: generate complete dev Postman API collection 2026-09-15 16:32:42 -03:00
Alexandre Brandizzi
5cf7f7da8c docs: add dev Postman starter collection 2026-09-15 16:32:42 -03:00
Alexandre Brandizzi
1a6edd255a
feat(locations): filter sites by state (#117)
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
2026-09-15 16:32:30 -03:00
Alexandre Brandizzi
3a4af64a64
fix(observability): preserve backend release identity (#114) 2026-09-15 16:08:14 -03:00
Alexandre Brandizzi
67089c2135
SH-281: group vendor directory by company (#115)
* feat(vendors): group directory by company

* style(vendors): format company directory query

* fix(vendors): preserve technician list contract
2026-09-15 16:02:44 -03:00
28 changed files with 21583 additions and 110 deletions

View file

@ -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()
{

View 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");
}
}

View file

@ -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()
{

View file

@ -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]

View file

@ -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]

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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}")]

View file

@ -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)

View file

@ -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)
{

View file

@ -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)

View file

@ -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)

View file

@ -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);

View file

@ -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);

View file

@ -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>();
}
}

View file

@ -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

View 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();
}
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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,

View file

@ -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)

View file

@ -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
View 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.

File diff suppressed because it is too large Load diff

View 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"
}

View 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);
}

View file

@ -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