mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Merge remote-tracking branch 'origin/dev' into feat/ab/sh-303-services-registry
# Conflicts: # Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
This commit is contained in:
commit
45f98e8154
27 changed files with 4963 additions and 60 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -253,6 +253,25 @@ namespace Data.SeaHavenIndustries
|
|||
.WithMany(s => s.SupportedWorkOrderTypes)
|
||||
.HasForeignKey(t => t.ServiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// 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; }
|
||||
|
|
@ -275,6 +294,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; }
|
||||
|
|
|
|||
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -2352,6 +2381,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)");
|
||||
|
||||
|
|
@ -2409,6 +2441,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AreaId");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
|
|
@ -3750,6 +3784,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")
|
||||
|
|
|
|||
15
Data.SeaHavenIndustries/Models/Area.cs
Normal file
15
Data.SeaHavenIndustries/Models/Area.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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__";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue