feat(vendors): add admin-assigned vendor company Area

Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
This commit is contained in:
Alexandre Brandizzi 2026-09-16 11:34:45 -03:00
parent d07ac42452
commit 8515676f4d
27 changed files with 4963 additions and 60 deletions

View file

@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests
public async Task Create_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests
public async Task Create_DuplicateName_ReturnsStable409()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorRosterDuplicateNameException(
"database detail",
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests
public async Task Reconcile_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
};
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Patch_AddsTechnicians_Returns200WithRoster()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
}
[Fact]
public async Task AreaAssignmentForbidden_Returns403OnEveryMutation()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
var controller = NewController(service);
var results = new[]
{
await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None),
await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None),
await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None)
};
foreach (var result in results)
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
}
}

View file

@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
context.Areas.AddRange(
new Area { Id = 1, Name = "East", NormalizedName = "east" },
new Area { Id = 2, Name = "Central", NormalizedName = "central" });
await context.SaveChangesAsync();
var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech");
var service = new VendorCompanyRosterDataService(context);
VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new()
{
CompanyId = companyId,
Name = "Area Co",
Email = "area@example.com",
AreaIdProvided = provided,
AreaId = areaId,
Technicians = context.Vendors.Where(v => v.CompanyId == companyId)
.Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName })
.ToList()
};
var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None);
assigned.AreaId.Should().Be(2);
assigned.AreaName.Should().Be("Central");
var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None);
untouched.AreaId.Should().Be(2);
var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None);
cleared.AreaId.Should().BeNull();
cleared.AreaName.Should().BeNull();
(await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue();
(await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse();
}
}

View file

@ -3,6 +3,7 @@ using FluentAssertions;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterServiceTests
{
private static readonly ClaimsPrincipal Dispatcher = Principal("User");
private static readonly ClaimsPrincipal Admin = Principal("Admin");
private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) },
"Test"));
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>()
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.Technicians.Should().BeEmpty();
@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501)
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests
GoogleMapsUrl = "http://maps.google.com/acme"
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests
{
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
}
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
}
@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme Co",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501)
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests
Technicians = null!
};
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().BeEmpty();
}
@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests
{
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
}
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().ContainSingle();
captured.Technicians.Single().ContactName.Should().BeEmpty();
@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
};
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
}
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", cts.Token);
}, "42", Dispatcher, cts.Token);
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
}
@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests
}
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields!.Notes.Should().Be(notes);
}
@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
// non-null write model made the data layer bump RowVersion and rewrite every
@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(
@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.City.Should().Be("Norfolk");
@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests
.ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource();
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token);
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
}
private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId)
{
var model = SampleReadModel(7, 1);
model.AreaId = areaId;
return model;
}
[Fact]
public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync(false);
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(2));
var result = await NewService(data).CreateRosterAsync(
new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 },
"42",
Admin,
cts.Token);
captured!.AreaId.Should().Be(2);
result.AreaId.Should().Be(2);
data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once);
}
[Fact]
public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
foreach (int? requested in new int?[] { 3, null })
{
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = requested
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
}
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
var captured = new List<VendorCompanyRosterWriteModel>();
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured.Add(model))
.ReturnsAsync(ReadModelWithArea(1));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com",
AreaId = 1
}, "42", Dispatcher, CancellationToken.None);
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com"
}, "42", Dispatcher, CancellationToken.None);
captured.Should().HaveCount(2);
captured[0].AreaIdProvided.Should().BeTrue();
captured[0].AreaId.Should().Be(1);
captured[1].AreaIdProvided.Should().BeFalse();
}
[Fact]
public async Task Reconcile_AdminClearsAreaToUnassigned()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(null));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = null
}, "42", Admin, CancellationToken.None);
captured!.AreaIdProvided.Should().BeTrue();
captured.AreaId.Should().BeNull();
data.Verify(x => x.IsActiveAreaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(null));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
{
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
omitted.AreaIdProvided.Should().BeFalse();
cleared.AreaIdProvided.Should().BeTrue();
cleared.AreaId.Should().BeNull();
assigned.AreaIdProvided.Should().BeTrue();
assigned.AreaId.Should().Be(3);
}
}

View file

@ -72,6 +72,7 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()), Times.Never);
}
@ -155,6 +156,7 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
{
@ -203,6 +205,7 @@ public class VendorControllerTests
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>(),

View file

@ -470,12 +470,14 @@ public class VendorDataServiceTests
search: "alice",
trades: new[] { "Plumbing" },
locations: new[] { "Chicago, IL" },
jobBuckets: new[] { "under-50" }).ToQueryString();
jobBuckets: new[] { "under-50" },
areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString();
sql.Should().Contain("GROUP BY");
sql.Should().Contain("LEFT JOIN");
sql.Should().Contain("TradeSpecialties");
sql.Should().Contain("TotalJobs");
sql.Should().Contain("AreaId");
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
}
@ -1044,4 +1046,69 @@ public class VendorDataServiceTests
sql.Should().Contain("SELECT");
sql.Should().Contain("VendorCompanies");
}
[Fact]
public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows()
{
await using var context = NewContext();
var east = new Area { Id = 1, Name = "East", NormalizedName = "east" };
var west = new Area { Id = 3, Name = "West", NormalizedName = "west" };
context.Areas.AddRange(east, west);
var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east };
var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west };
var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" };
var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east };
context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly);
context.Vendors.AddRange(
new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false },
new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false },
new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var eastResult = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
eastResult.Items.Select(item => item.CompanyName)
.Should().BeEquivalentTo("East Co", "East Only");
eastResult.TotalCount.Should().Be(2);
var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co");
eastRow.AreaId.Should().Be(east.Id);
eastRow.AreaName.Should().Be("East");
eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East");
var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true,
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true });
eastOrUnassigned.Items.Select(item => item.CompanyName)
.Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co");
eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull();
var eastAndBoston = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "Boston, MA" },
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co");
var matchesNobody = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, areaFilter: new VendorAreaFilter());
matchesNobody.TotalCount.Should().Be(0);
matchesNobody.Items.Should().BeEmpty();
var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
unfiltered.TotalCount.Should().Be(5);
}
[Fact]
public async Task GetActiveAreasAsync_ExcludesArchivedAreas()
{
await using var context = NewContext();
context.Areas.AddRange(
new Area { Id = 1, Name = "East", NormalizedName = "east" },
new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false });
await context.SaveChangesAsync();
var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None);
areas.Select(area => area.Name).Should().Equal("East");
}
}

View file

@ -166,6 +166,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token))
.ReturnsAsync((new[]
{
@ -212,6 +213,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token), Times.Once);
}
@ -228,6 +230,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
@ -756,6 +759,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
@ -794,6 +799,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -822,6 +829,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -849,6 +858,8 @@ public class VendorServiceTests
SortOrder = index + 1
})
.ToList());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
@ -898,4 +909,96 @@ public class VendorServiceTests
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
}
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 3, Name = "West", NormalizedName = "west" }
});
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
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<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
return data;
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().Equal(1);
captured.IncludeUnassigned.Should().BeTrue();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
// 4 is not in the active catalogue (unknown or archived); "West" is a label.
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { "4", "West", "-3" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().BeEmpty();
captured.IncludeUnassigned.Should().BeFalse();
result.Items.Should().BeEmpty();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter()
{
VendorAreaFilter? captured = new();
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" });
captured.Should().BeNull();
data.Verify(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>());
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
.ReturnsAsync(new List<Area>
{
new() { Id = 3, Name = "West", NormalizedName = "west" },
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 4, Name = "california", NormalizedName = "california" }
});
var facets = await NewService(data).GetFacetsAsync(null, cts.Token);
facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal(
(4, "california"), (1, "East"), (3, "West"));
data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once);
}
}

View file

@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken);
var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (VendorRosterDuplicateNameException dup)
{
return Conflict(new
@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken);
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken);
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });

View file

@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null,
[FromQuery] string[]? areas = null,
CancellationToken cancellationToken = default)
{
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
trades,
locations,
jobBuckets,
areas,
cancellationToken);
var data = pagedResult.Items.Select(v => new
@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers
v.Notes,
v.IsActive,
v.TotalJobs,
v.AreaId,
v.AreaName,
Technicians = v.Technicians.Select(t => new
{
t.Id,

View file

@ -206,6 +206,25 @@ namespace Data.SeaHavenIndustries
builder.Entity<Trade>()
.HasIndex(t => t.NormalizedName)
.IsUnique();
// SH-278 Area catalogue: bounded names so the unique key is indexable.
builder.Entity<Area>()
.Property(a => a.Name)
.HasMaxLength(128);
builder.Entity<Area>()
.Property(a => a.NormalizedName)
.HasMaxLength(128);
builder.Entity<Area>()
.HasIndex(a => a.NormalizedName)
.IsUnique();
builder.Entity<VendorCompany>()
.HasOne(c => c.Area)
.WithMany()
.HasForeignKey(c => c.AreaId)
.OnDelete(DeleteBehavior.Restrict);
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -228,6 +247,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Trade> Trades { get; set; }
public DbSet<Area> Areas { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH278_VendorCompanyArea : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AreaId",
table: "VendorCompanies",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "Areas",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Areas", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_VendorCompanies_AreaId",
table: "VendorCompanies",
column: "AreaId");
migrationBuilder.CreateIndex(
name: "IX_Areas_NormalizedName",
table: "Areas",
column: "NormalizedName",
unique: true);
// SH-278 v1 seed of the four dispatcher territories confirmed by Product.
// Names match the merged SH-273 Sites mapping (East / Central / West /
// California). Ids are stable; later renames change only the display name.
// Existing vendor companies stay Unassigned (no backfill).
migrationBuilder.InsertData(
table: "Areas",
columns: new[] { "Id", "Name", "NormalizedName", "IsActive" },
values: new object[,]
{
{ 1, "East", "east", true },
{ 2, "Central", "central", true },
{ 3, "West", "west", true },
{ 4, "California", "california", true }
});
migrationBuilder.AddForeignKey(
name: "FK_VendorCompanies_Areas_AreaId",
table: "VendorCompanies",
column: "AreaId",
principalTable: "Areas",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VendorCompanies_Areas_AreaId",
table: "VendorCompanies");
migrationBuilder.DropTable(
name: "Areas");
migrationBuilder.DropIndex(
name: "IX_VendorCompanies_AreaId",
table: "VendorCompanies");
migrationBuilder.DropColumn(
name: "AreaId",
table: "VendorCompanies");
}
}
}

View file

@ -286,6 +286,35 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Area", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Areas");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{
b.Property<int>("Id")
@ -2237,6 +2266,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<int?>("AreaId")
.HasColumnType("int");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
@ -2294,6 +2326,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("AreaId");
b.HasIndex("NormalizedName")
.IsUnique();
@ -3614,6 +3648,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
{
b.HasOne("Data.SeaHavenIndustries.Area", "Area")
.WithMany()
.HasForeignKey("AreaId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Area");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")

View file

@ -0,0 +1,15 @@
namespace Data.SeaHavenIndustries
{
// SH-278: dispatcher territory catalogue for this Sea Haven deployment. Vendor
// companies carry zero or one Area by stable id; the display name is separate and
// may be renamed without changing assignments. Area is selected independently and
// is never derived from a company's State. Rows are organization-wide: vendor
// companies are not scoped to a customer Account.
public class Area
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string NormalizedName { get; set; } = "";
public bool IsActive { get; set; } = true;
}
}

View file

@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned.
public int? AreaId { get; set; }
[ForeignKey(nameof(AreaId))]
public Area? Area { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }

View file

@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) =>
_context.Areas
.AsNoTracking()
.AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken);
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
int? vendorId,
int? companyId,
@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation
Zip = c.Zip,
GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes,
AreaId = c.AreaId,
AreaName = c.Area != null ? c.Area.Name : null,
RowVersion = c.RowVersion
})
.FirstOrDefaultAsync(cancellationToken);
@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation
company.Zip = roster.Zip;
company.GoogleMapsUrl = roster.GoogleMapsUrl;
company.Notes = roster.Notes;
if (roster.AreaIdProvided)
company.AreaId = roster.AreaId;
company.LastModificationTime = now;
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
if (actorId.HasValue)
@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation
Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes,
AreaId = roster.AreaId,
CreatedDate = now,
createdby = roster.ActorUserId
};
@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation
company.GoogleMapsUrl = fields.GoogleMapsUrl;
if (fields.Notes != null)
company.Notes = fields.Notes;
if (fields.AreaIdProvided)
company.AreaId = fields.AreaId;
// Keep the denormalized company columns on existing technicians aligned
// with the company row (same invariant SaveRosterAsync maintains). This

View file

@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation
return query.OrderBy(c => c.Id);
}
public async Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken)
{
return await _context.Areas
.AsNoTracking()
.Where(a => a.IsActive)
.OrderBy(a => a.Id)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default)
{
// Phase 1: select and paginate bounded company group keys in SQL.
@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation
companies,
trades,
locations,
jobBuckets);
jobBuckets,
areaFilter);
var totalCount = await keyQuery.CountAsync(cancellationToken);
@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation
? new Dictionary<int, VendorCompany>()
: await _context.VendorCompanies
.AsNoTracking()
.Include(c => c.Area)
.Where(c => missingCompanyIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, cancellationToken);
@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
GoogleMapsUrl = primary.GoogleMapsUrl,
Notes = linked ? primary.CompanyNotes : null,
AreaId = linked ? primary.CompanyAreaId : null,
AreaName = linked ? primary.CompanyAreaName : null,
IsActive = primary.IsActive,
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null,
GoogleMapsUrl = company.GoogleMapsUrl,
Notes = company.Notes,
AreaId = company.AreaId,
AreaName = company.Area?.Name,
IsActive = true,
TotalJobs = 0,
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null)
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null)
{
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
var grouped = sourceRows
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation
SearchMatch = group.Max(row => row.SearchFlag),
CompanyMatch = group.Max(row => row.CompanyFlag),
TradeMatch = group.Max(row => row.TradeFlag),
LocationMatch = group.Max(row => row.LocationFlag)
LocationMatch = group.Max(row => row.LocationFlag),
AreaMatch = group.Max(row => row.AreaFlag)
});
// Each facet selects the group when any status-eligible row matches it
@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation
if (selectedLocations is { Length: > 0 })
grouped = grouped.Where(key => key.LocationMatch == 1);
if (areaFilter != null)
grouped = grouped.Where(key => key.AreaMatch == 1);
var selectedJobBuckets = jobBuckets?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation
string? search,
IReadOnlyCollection<string>? companies,
IReadOnlyCollection<string>? trades,
IReadOnlyCollection<string>? locations)
IReadOnlyCollection<string>? locations,
VendorAreaFilter? areaFilter)
{
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation
var locationBody = selectedLocations is { Length: > 0 }
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
: null;
var areaBody = areaFilter == null
? null
: BuildAreaMatchBody(row, areaFilter);
var nameKey = Expression.Condition(
Expression.NotEqual(
@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))),
row);
}
@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation
CompanyCity = v.Company != null ? v.Company.City : null,
CompanyState = v.Company != null ? v.Company.State : null,
CompanyNotes = v.Company != null ? v.Company.Notes : null,
CompanyAreaId = v.Company != null ? v.Company.AreaId : null,
CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null,
ContactName = v.ContactName,
Email = v.Email,
Phone = v.Phone,
@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = v.TradeSpecialties,
GoogleMapsUrl = v.GoogleMapsUrl,
Notes = v.Company != null ? v.Company.Notes : null,
AreaId = v.Company != null ? v.Company.AreaId : null,
IsActive = v.IsActive,
TotalJobs = (int?)jobCount.TotalJobs ?? 0
};
@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null,
GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes,
AreaId = c.AreaId,
IsActive = true,
TotalJobs = 0
});
@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation
return body!;
}
// SH-278: OR within the Area facet. Selected ids match the company's stable
// AreaId; the Unassigned sentinel matches rows without an Area, which includes
// unlinked legacy vendors that have no VendorCompany at all. A filter with no
// resolvable ids and no Unassigned matches nobody.
private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter)
{
var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId));
Expression? body = null;
if (areaFilter.AreaIds.Count > 0)
{
var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray();
body = OrElse(body, Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Contains),
new[] { typeof(int?) },
Expression.Constant(ids),
areaId));
}
if (areaFilter.IncludeUnassigned)
body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?))));
return body ?? Expression.Constant(false);
}
private static Expression BuildCompanyNameMatchBody(
ParameterExpression row,
IReadOnlyCollection<string> selectedCompanies)

View file

@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorCompanyRosterDataService
{
// SH-278: true only for an existing, non-archived Area in the catalogue.
Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken);
// Loads the company roster from either a vendor row id and/or a company id,
// returning the company plus all non-deleted technician rows.
Task<VendorCompanyRosterReadModel?> GetRosterAsync(

View file

@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
// of technician Vendor.TradeSpecialties free text.
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
// SH-278: active (non-archived) Area catalogue rows for vendor facets and for
// resolving areas[n] filter ids. Archived Areas are excluded.
Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken);
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
// companies; active includes companies with zero non-deleted technicians or at
@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default);
Task<Vendor> AddAsync(Vendor vendor);
Task UpdateAsync(Vendor vendor);

View file

@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public byte[]? RowVersion { get; set; }
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
}
@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
public bool AreaIdProvided { get; set; }
public int? AreaId { get; set; }
public string? ActorUserId { get; set; }
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
}
@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
public bool AreaIdProvided { get; set; }
public int? AreaId { get; set; }
}
// Raised by the roster data service when a technician removed from the snapshot

View file

@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
}
// SH-278: Area facet filter for the company directory. AreaIds are already
// resolved against the active catalogue; IncludeUnassigned selects groups with no
// Area (including unlinked legacy vendors). An empty filter matches nobody.
public sealed class VendorAreaFilter
{
public IReadOnlyCollection<int> AreaIds { get; init; } = Array.Empty<int>();
public bool IncludeUnassigned { get; init; }
}
// 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
@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models
public int CompanyFlag { get; set; }
public int TradeFlag { get; set; }
public int LocationFlag { get; set; }
public int AreaFlag { get; set; }
}
// SH-281: one row per company group after SQL grouping — the bounded key page.
@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models
public int CompanyMatch { get; set; }
public int TradeMatch { get; set; }
public int LocationMatch { get; set; }
public int AreaMatch { get; set; }
}
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models
public string? CompanyCity { get; set; }
public string? CompanyState { get; set; }
public string? CompanyNotes { get; set; }
public int? CompanyAreaId { get; set; }
public string? CompanyAreaName { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();

View file

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace SeaHaven.Services.DTOs
{
public class VendorRosterDTO
@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public string? RowVersion { get; set; }
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
}
@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: optional Area id; null leaves the new company Unassigned. Assigning
// an Area requires the Admin role.
public int? AreaId { get; set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: an omitted areaId leaves the stored Area unchanged, so clients that
// predate Area never clear it. An explicit null clears it to Unassigned.
private int? _areaId;
public int? AreaId
{
get => _areaId;
set
{
_areaId = value;
AreaIdProvided = true;
}
}
[JsonIgnore]
public bool AreaIdProvided { get; private set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId.
private int? _areaId;
public int? AreaId
{
get => _areaId;
set
{
_areaId = value;
AreaIdProvided = true;
}
}
[JsonIgnore]
public bool AreaIdProvided { get; private set; }
}
// SH-278: raised when a caller without the Admin role tries to change a vendor
// company's Area. Mapped to a stable 403 by the API.
public sealed class VendorAreaAssignmentForbiddenException : Exception
{
public VendorAreaAssignmentForbiddenException()
: base("Only administrators can assign a vendor company Area.")
{
}
}
}

View file

@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public int TotalJobs { get; set; }
public bool IsActive { get; set; }
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();

View file

@ -59,7 +59,21 @@ namespace SeaHaven.Services.DTOs
{
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
public IEnumerable<VendorFacetAreaDTO> Areas { get; set; } = Enumerable.Empty<VendorFacetAreaDTO>();
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
}
// SH-278: one active Area option. Id is the stable areas[n] filter value.
public class VendorFacetAreaDTO
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
// SH-278: wire values accepted by the vendor directory areas[n] filter besides ids.
public static class VendorAreaFilterValues
{
public const string Unassigned = "__unassigned__";
}
}

View file

@ -6,6 +6,7 @@ using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using System.Security.Claims;
namespace SeaHaven.Services.Implementation
{
@ -13,6 +14,8 @@ namespace SeaHaven.Services.Implementation
{
private const int MaxNotesLength = 500;
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
private const string AdminRole = "Admin";
private const string AreaNotFoundMessage = "Area was not found.";
private readonly IVendorCompanyRosterDataService _rosterDataService;
@ -39,6 +42,7 @@ namespace SeaHaven.Services.Implementation
public async Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -60,6 +64,13 @@ namespace SeaHaven.Services.Implementation
"Technician ids are not allowed when creating a vendor company.")
});
await EnsureAreaAssignmentAllowedAsync(
caller,
dto.AreaId,
currentAreaId: null,
nameof(CreateVendorRosterDTO.AreaId),
cancellationToken);
var writeModel = new VendorCompanyRosterWriteModel
{
Name = dto.Name,
@ -71,6 +82,8 @@ namespace SeaHaven.Services.Implementation
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
AreaIdProvided = true,
AreaId = dto.AreaId,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
@ -83,6 +96,7 @@ namespace SeaHaven.Services.Implementation
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -101,6 +115,14 @@ namespace SeaHaven.Services.Implementation
// Mismatched ids: every non-null technician id must belong to this company.
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
if (dto.AreaIdProvided)
await EnsureAreaChangeAllowedAsync(
companyId,
caller,
dto.AreaId,
nameof(ReconcileVendorRosterDTO.AreaId),
cancellationToken);
var writeModel = new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
@ -114,6 +136,8 @@ namespace SeaHaven.Services.Implementation
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
AreaIdProvided = dto.AreaIdProvided,
AreaId = dto.AreaId,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
@ -126,6 +150,7 @@ namespace SeaHaven.Services.Implementation
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -167,6 +192,14 @@ namespace SeaHaven.Services.Implementation
if (failures.Count > 0)
throw new ValidationException(failures);
if (companyFields is { AreaIdProvided: true })
await EnsureAreaChangeAllowedAsync(
companyId,
caller,
companyFields.AreaId,
$"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}",
cancellationToken);
// The contact-group invariant ("at least one company phone or email") is
// preserved by construction: blank company fields map to null ("unchanged"),
// so this endpoint can only set valid phone/email values, never clear them.
@ -184,6 +217,50 @@ namespace SeaHaven.Services.Implementation
return MapToDTO(saved);
}
// SH-278: Area assignment is Admin-only. A payload that leaves the stored Area
// unchanged is allowed for any authenticated caller, so non-admin users can
// still save other company fields while round-tripping the current Area.
private async Task EnsureAreaChangeAllowedAsync(
int companyId,
ClaimsPrincipal caller,
int? requestedAreaId,
string propertyName,
CancellationToken cancellationToken)
{
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken)
?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found.");
await EnsureAreaAssignmentAllowedAsync(
caller,
requestedAreaId,
roster.AreaId,
propertyName,
cancellationToken);
}
private async Task EnsureAreaAssignmentAllowedAsync(
ClaimsPrincipal caller,
int? requestedAreaId,
int? currentAreaId,
string propertyName,
CancellationToken cancellationToken)
{
if (requestedAreaId == currentAreaId)
return;
if (caller?.IsInRole(AdminRole) != true)
throw new VendorAreaAssignmentForbiddenException();
if (requestedAreaId is int areaId
&& (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken)))
{
throw new ValidationException(new[]
{
new ValidationFailure(propertyName, AreaNotFoundMessage)
});
}
}
private static void EnsureAuthenticated(string userId)
{
if (string.IsNullOrWhiteSpace(userId))
@ -290,7 +367,8 @@ namespace SeaHaven.Services.Implementation
|| !string.IsNullOrWhiteSpace(fields.State)
|| !string.IsNullOrWhiteSpace(fields.Zip)
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|| !string.IsNullOrWhiteSpace(fields.Notes);
|| !string.IsNullOrWhiteSpace(fields.Notes)
|| fields.AreaIdProvided;
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
model.Name == null
@ -301,7 +379,8 @@ namespace SeaHaven.Services.Implementation
&& model.State == null
&& model.Zip == null
&& model.GoogleMapsUrl == null
&& model.Notes == null;
&& model.Notes == null
&& !model.AreaIdProvided;
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
VendorRosterCompanyFieldsDTO fields,
@ -344,7 +423,9 @@ namespace SeaHaven.Services.Implementation
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes,
AreaIdProvided = fields.AreaIdProvided,
AreaId = fields.AreaId
};
}
@ -443,6 +524,8 @@ namespace SeaHaven.Services.Implementation
Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes,
AreaId = roster.AreaId,
AreaName = roster.AreaName,
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
{

View file

@ -4,6 +4,7 @@ using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Globalization;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
@ -160,10 +161,12 @@ namespace SeaHaven.Services.Implementation
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
IReadOnlyCollection<string>? areas = null,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken);
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
normalizedPage,
normalizedPageSize,
@ -173,6 +176,7 @@ namespace SeaHaven.Services.Implementation
trades,
locations,
jobBuckets,
areaFilter,
cancellationToken);
return new PagedResult<VendorDirectoryItemDTO>
@ -184,6 +188,47 @@ namespace SeaHaven.Services.Implementation
};
}
// SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects
// companies without an Area; other values must be stable positive integer ids
// of active catalogue rows. Display names, unknown ids and archived ids never
// error — they simply match nobody. Null means no Area filter was requested.
private async Task<VendorAreaFilter?> ResolveAreaFilterAsync(
IReadOnlyCollection<string>? areas,
CancellationToken cancellationToken)
{
var values = areas?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.ToList();
if (values is not { Count: > 0 })
return null;
var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal);
var requestedIds = new HashSet<int>();
foreach (var value in values)
{
if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0)
requestedIds.Add(id);
}
IReadOnlyCollection<int> activeIds = Array.Empty<int>();
if (requestedIds.Count > 0)
{
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
activeIds = activeAreas
.Select(area => area.Id)
.Where(requestedIds.Contains)
.ToArray();
}
return new VendorAreaFilter
{
AreaIds = activeIds,
IncludeUnassigned = includeUnassigned
};
}
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
{
Id = group.Id,
@ -201,6 +246,8 @@ namespace SeaHaven.Services.Implementation
TradeSpecialties = group.TradeSpecialties,
GoogleMapsUrl = group.GoogleMapsUrl,
Notes = group.Notes,
AreaId = group.AreaId,
AreaName = group.AreaName,
IsActive = group.IsActive,
TotalJobs = group.TotalJobs,
Technicians = group.Technicians.Select(MapToTechnician).ToList()
@ -731,6 +778,7 @@ namespace SeaHaven.Services.Implementation
// populated even on an empty database and independent of the status filter.
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
var companyFacets = companies
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
@ -772,10 +820,19 @@ namespace SeaHaven.Services.Implementation
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
// SH-278: the active Area catalogue, A–Z case-insensitive, independent of the
// status filter and of whether any company currently uses an Area. Clients
// prepend the "__unassigned__" option themselves.
var areas = activeAreas
.OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase)
.Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name })
.ToList();
return new VendorFacetsDTO
{
Companies = companyFacets,
Trades = trades,
Areas = areas,
Locations = locations,
JobBuckets = new[]
{

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
@ -13,18 +14,21 @@ namespace SeaHaven.Services.Interfaces
Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<VendorRosterDTO> ReconcileRosterAsync(
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<VendorRosterDTO> AddTechniciansAsync(
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
}
}

View file

@ -41,6 +41,7 @@ namespace SeaHaven.Services.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
IReadOnlyCollection<string>? areas = null,
CancellationToken cancellationToken = default);
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);