mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 11:22:09 +00:00
Merge branch 'dev' into feat/sh-254-be-confirm-deactivation
This commit is contained in:
commit
6fffc1b591
10 changed files with 1019 additions and 24 deletions
|
|
@ -213,4 +213,75 @@ public class VendorCompanyRosterControllerTests
|
||||||
serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||||
serverError.Value!.ToString()!.Should().NotContain("secret stack details");
|
serverError.Value!.ToString()!.Should().NotContain("secret stack details");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AddTechniciansVendorRosterDTO PatchDto() => new()
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "Riley", IsActive = true } }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Patch_Unauthenticated_Returns401()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
var controller = NewController(service, userId: null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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>()))
|
||||||
|
.ReturnsAsync(SampleRoster());
|
||||||
|
|
||||||
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||||
|
ok.Value.Should().BeEquivalentTo(SampleRoster());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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>()))
|
||||||
|
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
|
||||||
|
|
||||||
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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>()))
|
||||||
|
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||||
|
|
||||||
|
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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>()))
|
||||||
|
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||||
|
|
||||||
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
||||||
|
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
|
||||||
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||||
|
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,14 @@ public class VendorCompanyRosterDataServiceTests
|
||||||
return new ApplicationDbContext(options);
|
return new ApplicationDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(ApplicationDbContext context, string companyName, params string[] technicianNames)
|
private static Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(
|
||||||
|
ApplicationDbContext context, string companyName, params string[] technicianNames)
|
||||||
|
=> SeedCompanyWithTechnicianAsync(context, companyName, rowVersion: null, technicianNames);
|
||||||
|
|
||||||
|
private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(
|
||||||
|
ApplicationDbContext context, string companyName, byte[]? rowVersion, params string[] technicianNames)
|
||||||
{
|
{
|
||||||
var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant() };
|
var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant(), RowVersion = rowVersion };
|
||||||
context.VendorCompanies.Add(company);
|
context.VendorCompanies.Add(company);
|
||||||
|
|
||||||
foreach (var name in technicianNames)
|
foreach (var name in technicianNames)
|
||||||
|
|
@ -492,4 +497,322 @@ public class VendorCompanyRosterDataServiceTests
|
||||||
tech.Zip.Should().Be("00005");
|
tech.Zip.Should().Be("00005");
|
||||||
tech.GoogleMapsUrl.Should().Be("https://create.example/map");
|
tech.GoogleMapsUrl.Should().Be("https://create.example/map");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static byte[] InitialRowVersion() => new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_RenameToTakenName_ThrowsDuplicateNameConflict()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Gateway Plumbing", InitialRowVersion(), "First");
|
||||||
|
companyId = cid;
|
||||||
|
await SeedCompanyWithTechnicianAsync(seed, "Harbor Electric", InitialRowVersion(), "Other");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
var call = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsWriteModel { Name = "Harbor Electric" },
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel>()
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
// SH-250: a colliding rename must surface as a stable client conflict, not as an
|
||||||
|
// unhandled DbUpdateException that the controller reports as a 500.
|
||||||
|
await call.Should().ThrowAsync<VendorRosterDuplicateNameException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
verify.VendorCompanies.Single(c => c.Id == companyId).Name.Should().Be("Gateway Plumbing");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_RenameToFreeName_Succeeds()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Gateway Plumbing", InitialRowVersion(), "First");
|
||||||
|
companyId = cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsWriteModel { Name = "Gateway Plumbing & Drain" },
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel>()
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var company = verify.VendorCompanies.Single(c => c.Id == companyId);
|
||||||
|
company.Name.Should().Be("Gateway Plumbing & Drain");
|
||||||
|
company.NormalizedName.Should().Be("gateway plumbing & drain");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_LeavesAllPreExistingTechniciansIntact()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, firstId, secondId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Add Co", InitialRowVersion(), "First", "Second");
|
||||||
|
companyId = cid;
|
||||||
|
firstId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "First").Id;
|
||||||
|
secondId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Second").Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
var roster = await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Third", Phone = "(312) 555-0100", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "First", "Second", "Third" });
|
||||||
|
Convert.ToBase64String(roster.RowVersion!).Should().NotBe(Convert.ToBase64String(InitialRowVersion()));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var first = verify.Vendors.Single(v => v.Id == firstId);
|
||||||
|
first.IsDeleted.Should().NotBeTrue();
|
||||||
|
first.IsActive.Should().BeTrue();
|
||||||
|
first.ContactName.Should().Be("First");
|
||||||
|
var second = verify.Vendors.Single(v => v.Id == secondId);
|
||||||
|
second.IsDeleted.Should().NotBeTrue();
|
||||||
|
second.IsActive.Should().BeTrue();
|
||||||
|
second.ContactName.Should().Be("Second");
|
||||||
|
var added = verify.Vendors.Single(v => v.ContactName == "Third");
|
||||||
|
added.CompanyId.Should().Be(companyId);
|
||||||
|
added.IsActive.Should().BeTrue();
|
||||||
|
added.Phone.Should().Be("(312) 555-0100");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_SucceedsWhenExistingTechniciansHaveOpenWorkOrders()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, busyId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Busy Co", InitialRowVersion(), "Busy");
|
||||||
|
companyId = cid;
|
||||||
|
busyId = vendor.Id;
|
||||||
|
seed.workOrders.Add(new WorkOrder { Id = 900, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled" });
|
||||||
|
seed.Dispatches.Add(new Dispatch { VendorId = busyId, WorkOrderId = 900, Vendor = vendor });
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
var roster = await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Newcomer", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "Busy", "Newcomer" });
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var busy = verify.Vendors.Single(v => v.Id == busyId);
|
||||||
|
busy.IsDeleted.Should().NotBeTrue();
|
||||||
|
busy.IsActive.Should().BeTrue();
|
||||||
|
verify.Vendors.Count(v => v.CompanyId == companyId && (v.IsDeleted == null || v.IsDeleted == false)).Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_StaleRowVersion_ThrowsConcurrencyAndLeavesCompanyUnchanged()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Race Co", InitialRowVersion(), "Original");
|
||||||
|
companyId = cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var concurrentWriter = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var company = concurrentWriter.VendorCompanies.Single(c => c.Id == companyId);
|
||||||
|
company.Notes = "modified concurrently";
|
||||||
|
await concurrentWriter.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
var actCall = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Should Not Persist", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
await actCall.Should().ThrowAsync<DbUpdateConcurrencyException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stale save must not clobber the concurrent writer's company state. (The
|
||||||
|
// InMemory provider does not roll back applied inserts when the rowversion-guarded
|
||||||
|
// update fails; on SQL Server the implicit SaveChanges transaction makes the whole
|
||||||
|
// batch atomic — the same mechanism the PUT reconcile relies on.)
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var stored = verify.VendorCompanies.Single(c => c.Id == companyId);
|
||||||
|
stored.Notes.Should().Be("modified concurrently");
|
||||||
|
stored.RowVersion.Should().NotEqual(InitialRowVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_UnknownCompany_ThrowsKeyNotFound()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
var act = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = 404,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Ghost", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
context.Vendors.Should().BeEmpty();
|
||||||
|
context.VendorCompanies.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_WithCompanyFields_UpdatesOnlyProvidedFieldsAndPropagatesToExisting()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, existingId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var company = new VendorCompany
|
||||||
|
{
|
||||||
|
Name = "Field Co",
|
||||||
|
NormalizedName = "field co",
|
||||||
|
CompanyPhone = "(111) 111-0001",
|
||||||
|
Email = "keep@example.com",
|
||||||
|
Address = "Old Addr",
|
||||||
|
City = "OldCity",
|
||||||
|
Notes = "keep notes",
|
||||||
|
RowVersion = InitialRowVersion()
|
||||||
|
};
|
||||||
|
seed.VendorCompanies.Add(company);
|
||||||
|
seed.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = "Field Co",
|
||||||
|
ContactName = "Keeper",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Company = company,
|
||||||
|
CompanyPhone = "(111) 111-0001",
|
||||||
|
Address = "Old Addr",
|
||||||
|
City = "OldCity"
|
||||||
|
});
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
companyId = company.Id;
|
||||||
|
existingId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsWriteModel
|
||||||
|
{
|
||||||
|
Name = "Renamed Co",
|
||||||
|
CompanyPhone = "(222) 222-0002",
|
||||||
|
City = "NewCity"
|
||||||
|
},
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Fresh", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var companyRow = verify.VendorCompanies.Single(c => c.Id == companyId);
|
||||||
|
companyRow.Name.Should().Be("Renamed Co");
|
||||||
|
companyRow.NormalizedName.Should().Be("renamed co");
|
||||||
|
companyRow.CompanyPhone.Should().Be("(222) 222-0002");
|
||||||
|
companyRow.City.Should().Be("NewCity");
|
||||||
|
companyRow.Email.Should().Be("keep@example.com");
|
||||||
|
companyRow.Address.Should().Be("Old Addr");
|
||||||
|
companyRow.Notes.Should().Be("keep notes");
|
||||||
|
|
||||||
|
var existing = verify.Vendors.Single(v => v.Id == existingId);
|
||||||
|
existing.IsDeleted.Should().NotBeTrue();
|
||||||
|
existing.IsActive.Should().BeTrue();
|
||||||
|
existing.CompanyName.Should().Be("Renamed Co");
|
||||||
|
existing.CompanyPhone.Should().Be("(222) 222-0002");
|
||||||
|
existing.City.Should().Be("NewCity");
|
||||||
|
|
||||||
|
var added = verify.Vendors.Single(v => v.ContactName == "Fresh");
|
||||||
|
added.CompanyName.Should().Be("Renamed Co");
|
||||||
|
added.CompanyPhone.Should().Be("(222) 222-0002");
|
||||||
|
added.City.Should().Be("NewCity");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_ForwardsCancellation()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
var act = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = 1,
|
||||||
|
RowVersion = InitialRowVersion(),
|
||||||
|
ActorUserId = "42",
|
||||||
|
AddTechnicians = new List<RosterTechnicianWriteModel> { new() { ContactName = "T" } }
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -380,4 +380,192 @@ public class VendorCompanyRosterServiceTests
|
||||||
|
|
||||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AddTechniciansVendorRosterDTO AddDto(params RosterTechnicianInputDTO[] technicians) => new()
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
AddTechnicians = technicians.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_RejectsTechnicianIds()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
|
||||||
|
|
||||||
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_RejectsEmptyPayload()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
|
||||||
|
|
||||||
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_RejectsMissingRowVersion()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "",
|
||||||
|
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_RejectsInvalidTechnicianPhoneFormat()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_NormalizesAndMapsBeforeForwarding()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
VendorCompanyRosterAddWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
AddTechnicians = new List<RosterTechnicianInputDTO>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Riley", Phone = "+1 312 555 0100", TradeSpecialties = "HVAC" }
|
||||||
|
},
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsDTO
|
||||||
|
{
|
||||||
|
Name = " Renamed Co ",
|
||||||
|
Notes = "fresh notes"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.CompanyId.Should().Be(7);
|
||||||
|
captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I="));
|
||||||
|
captured.ActorUserId.Should().Be("42");
|
||||||
|
captured.AddTechnicians.Single().Phone.Should().Be("(312) 555-0100");
|
||||||
|
captured.AddTechnicians.Single().TradeSpecialties.Should().Be("HVAC");
|
||||||
|
captured.AddTechnicians.Single().IsActive.Should().BeTrue();
|
||||||
|
captured.CompanyFields.Should().NotBeNull();
|
||||||
|
captured.CompanyFields!.Name.Should().Be("Renamed Co");
|
||||||
|
captured.CompanyFields.Notes.Should().Be("fresh notes");
|
||||||
|
captured.CompanyFields.CompanyPhone.Should().BeNull();
|
||||||
|
captured.CompanyFields.Email.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_BlankCompanyFieldsMeanUnchangedAndKeepContactGroup()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
VendorCompanyRosterAddWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } },
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
await NewService(data).AddTechniciansAsync(7, dto, "42", 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
|
||||||
|
// technician's LastModificationTime for a request that changes no company data.
|
||||||
|
captured!.CompanyFields.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_AllBlankCompanyFieldsAndNoTechnicians_FailsValidation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
AddTechnicians = new List<RosterTechnicianInputDTO>(),
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
data.Verify(
|
||||||
|
x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_RealCompanyValueStillForwarded()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
VendorCompanyRosterAddWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
AddTechnicians = new List<RosterTechnicianInputDTO>(),
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
|
||||||
|
};
|
||||||
|
|
||||||
|
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
captured!.CompanyFields.Should().NotBeNull();
|
||||||
|
captured.CompanyFields!.City.Should().Be("Norfolk");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_ForwardsCancellation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
|
||||||
|
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,5 +139,61 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additive partial update (SH-250): inserts the submitted technicians and
|
||||||
|
// optionally updates company fields. A technician absent from the payload is
|
||||||
|
// never removed, so 409 here can only mean a stale row version.
|
||||||
|
[HttpPatch("{companyId:int}")]
|
||||||
|
public async Task<IActionResult> AddTechnicians(
|
||||||
|
int companyId,
|
||||||
|
[FromBody] AddTechniciansVendorRosterDTO model,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userId == null)
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken);
|
||||||
|
return Ok(roster);
|
||||||
|
}
|
||||||
|
catch (ValidationException vex)
|
||||||
|
{
|
||||||
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
|
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||||
|
}
|
||||||
|
catch (VendorRosterDuplicateNameException dup)
|
||||||
|
{
|
||||||
|
// SH-250: a colliding rename is a client conflict, not a server fault.
|
||||||
|
return Conflict(new
|
||||||
|
{
|
||||||
|
Status = "Conflict",
|
||||||
|
Message = _logger.Sanitize(dup, "Another vendor company already uses that name."),
|
||||||
|
Code = 409
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
return Conflict(new
|
||||||
|
{
|
||||||
|
Status = "Conflict",
|
||||||
|
Message = "The vendor company was modified by another user. Refresh and retry.",
|
||||||
|
Code = 409
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,149 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
?? throw new InvalidOperationException("Roster could not be reloaded after create.");
|
?? throw new InvalidOperationException("Roster could not be reloaded after create.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<VendorCompanyRosterReadModel> AddTechniciansAsync(
|
||||||
|
VendorCompanyRosterAddWriteModel roster,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var company = await _context.VendorCompanies
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == roster.CompanyId, cancellationToken);
|
||||||
|
|
||||||
|
if (company == null)
|
||||||
|
throw new KeyNotFoundException($"Vendor company with ID {roster.CompanyId} not found.");
|
||||||
|
|
||||||
|
// Concurrency: pin the client-supplied row version as the original value so a
|
||||||
|
// stale save throws DbUpdateConcurrencyException (mapped to a stable 409).
|
||||||
|
if (roster.RowVersion != null && roster.RowVersion.Length > 0)
|
||||||
|
_context.Entry(company).Property(c => c.RowVersion).OriginalValue = roster.RowVersion;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
|
||||||
|
string? renamedTo = null;
|
||||||
|
|
||||||
|
// Partial company update: only fields present in CompanyFields are applied;
|
||||||
|
// a null field leaves the stored value untouched. The company row is always
|
||||||
|
// touched so the concurrency token fires on every additive save.
|
||||||
|
if (roster.CompanyFields != null)
|
||||||
|
{
|
||||||
|
var fields = roster.CompanyFields;
|
||||||
|
|
||||||
|
if (fields.Name != null)
|
||||||
|
{
|
||||||
|
var trimmedName = fields.Name.Trim();
|
||||||
|
var normalized = trimmedName.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (normalized != company.NormalizedName)
|
||||||
|
{
|
||||||
|
var nameTaken = await _context.VendorCompanies
|
||||||
|
.AnyAsync(
|
||||||
|
other => other.Id != company.Id
|
||||||
|
&& (other.IsDeleted == null || other.IsDeleted == false)
|
||||||
|
&& other.NormalizedName == normalized,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (nameTaken)
|
||||||
|
throw new VendorRosterDuplicateNameException(
|
||||||
|
"Another vendor company already uses that name.",
|
||||||
|
new InvalidOperationException(
|
||||||
|
$"NormalizedName '{normalized}' is already in use."));
|
||||||
|
|
||||||
|
renamedTo = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
company.Name = trimmedName;
|
||||||
|
company.NormalizedName = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.CompanyPhone != null)
|
||||||
|
company.CompanyPhone = fields.CompanyPhone;
|
||||||
|
if (fields.Email != null)
|
||||||
|
company.Email = fields.Email;
|
||||||
|
if (fields.Address != null)
|
||||||
|
company.Address = fields.Address;
|
||||||
|
if (fields.City != null)
|
||||||
|
company.City = fields.City;
|
||||||
|
if (fields.State != null)
|
||||||
|
company.State = fields.State;
|
||||||
|
if (fields.Zip != null)
|
||||||
|
company.Zip = fields.Zip;
|
||||||
|
if (fields.GoogleMapsUrl != null)
|
||||||
|
company.GoogleMapsUrl = fields.GoogleMapsUrl;
|
||||||
|
if (fields.Notes != null)
|
||||||
|
company.Notes = fields.Notes;
|
||||||
|
|
||||||
|
// Keep the denormalized company columns on existing technicians aligned
|
||||||
|
// with the company row (same invariant SaveRosterAsync maintains). This
|
||||||
|
// never removes, deactivates or rewrites technician-owned fields.
|
||||||
|
var existingTechnicians = await _context.Vendors
|
||||||
|
.Where(v => v.CompanyId == company.Id && (v.IsDeleted == null || v.IsDeleted == false))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var existing in existingTechnicians)
|
||||||
|
{
|
||||||
|
existing.CompanyName = company.Name;
|
||||||
|
existing.CompanyPhone = company.CompanyPhone;
|
||||||
|
existing.Address = company.Address;
|
||||||
|
existing.City = company.City;
|
||||||
|
existing.State = company.State;
|
||||||
|
existing.Zip = company.Zip;
|
||||||
|
existing.GoogleMapsUrl = company.GoogleMapsUrl;
|
||||||
|
existing.LastModificationTime = now;
|
||||||
|
if (actorId.HasValue)
|
||||||
|
existing.LastModifierUserId = actorId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
company.LastModificationTime = now;
|
||||||
|
if (actorId.HasValue)
|
||||||
|
company.LastModifierUserId = actorId;
|
||||||
|
|
||||||
|
// Purely additive: every submitted technician is a new row. Existing
|
||||||
|
// technicians are deliberately not consulted for removal.
|
||||||
|
foreach (var technician in roster.AddTechnicians)
|
||||||
|
{
|
||||||
|
var vendor = new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = technician.ContactName,
|
||||||
|
Phone = technician.Phone,
|
||||||
|
Email = technician.Email,
|
||||||
|
PreferredContact = technician.PreferredContact,
|
||||||
|
TradeSpecialties = technician.TradeSpecialties,
|
||||||
|
IsActive = technician.IsActive,
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CompanyPhone = company.CompanyPhone,
|
||||||
|
Address = company.Address,
|
||||||
|
City = company.City,
|
||||||
|
State = company.State,
|
||||||
|
Zip = company.Zip,
|
||||||
|
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||||
|
CreatedDate = now,
|
||||||
|
createdby = roster.ActorUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _context.Vendors.AddAsync(vendor, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-250: a rename can collide with the unique NormalizedName index. Without
|
||||||
|
// this guard the DbUpdateException reached the controller's generic handler
|
||||||
|
// and surfaced as a 500, even though SQL Server rolls the batch back cleanly.
|
||||||
|
// The pre-check above handles the ordinary case; this covers the race where a
|
||||||
|
// competing rename commits between that check and this save.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (renamedTo != null && ex is not DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new VendorRosterDuplicateNameException(
|
||||||
|
"Another vendor company already uses that name.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GetRosterAsync(null, company.Id, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Roster could not be reloaded after save.");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(
|
private async Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(
|
||||||
IReadOnlyCollection<int> vendorIds,
|
IReadOnlyCollection<int> vendorIds,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
|
|
@ -24,5 +24,13 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<VendorCompanyRosterReadModel> CreateRosterAsync(
|
Task<VendorCompanyRosterReadModel> CreateRosterAsync(
|
||||||
VendorCompanyRosterWriteModel roster,
|
VendorCompanyRosterWriteModel roster,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// Additive update (SH-250): inserts the submitted technicians and applies only
|
||||||
|
// the company fields present in CompanyFields (null = unchanged). Technicians
|
||||||
|
// absent from the payload are never removed, soft-deleted or deactivated, so no
|
||||||
|
// open-work-order check is needed. Persists in a single SaveChangesAsync.
|
||||||
|
Task<VendorCompanyRosterReadModel> AddTechniciansAsync(
|
||||||
|
VendorCompanyRosterAddWriteModel roster,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,49 @@ namespace SeaHaven.DataServices.Models
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additive roster update (SH-250): AddTechnicians rows are inserted and nothing
|
||||||
|
// is ever removed. CompanyFields carries optional company-level updates where a
|
||||||
|
// null property means "leave unchanged".
|
||||||
|
public sealed class VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
public string? ActorUserId { get; set; }
|
||||||
|
public VendorRosterCompanyFieldsWriteModel? CompanyFields { get; set; }
|
||||||
|
public List<RosterTechnicianWriteModel> AddTechnicians { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VendorRosterCompanyFieldsWriteModel
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
// Raised by the roster data service when a technician removed from the snapshot
|
// Raised by the roster data service when a technician removed from the snapshot
|
||||||
// still has open linked work orders, so the whole reconcile must fail. Carries the
|
// still has open linked work orders, so the whole reconcile must fail. Carries the
|
||||||
// blocking work orders so the API can surface a stable 409 without leaking internals.
|
// blocking work orders so the API can surface a stable 409 without leaking internals.
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when a company rename collides with the unique NormalizedName index.
|
||||||
|
/// Distinct from <see cref="VendorRosterConflictException"/>, which reports open
|
||||||
|
/// linked work orders, so callers can return a stable client conflict instead of
|
||||||
|
/// letting the raw <see cref="Microsoft.EntityFrameworkCore.DbUpdateException"/>
|
||||||
|
/// surface as a 500.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VendorRosterDuplicateNameException : Exception
|
||||||
|
{
|
||||||
|
public VendorRosterDuplicateNameException(string message, Exception inner)
|
||||||
|
: base(message, inner)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class VendorRosterConflictException : Exception
|
public sealed class VendorRosterConflictException : Exception
|
||||||
{
|
{
|
||||||
public IReadOnlyList<LinkedWorkOrderInfo> BlockedWorkOrders { get; }
|
public IReadOnlyList<LinkedWorkOrderInfo> BlockedWorkOrders { get; }
|
||||||
|
|
|
||||||
|
|
@ -67,4 +67,27 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partial, purely additive roster update (SH-250): technicians listed here are
|
||||||
|
// inserted; any technician absent from the payload is never removed, soft-deleted
|
||||||
|
// or deactivated. Company fields are optional; a null field means "unchanged".
|
||||||
|
public class AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
public required string RowVersion { get; set; }
|
||||||
|
public List<RosterTechnicianInputDTO> AddTechnicians { get; set; } = new();
|
||||||
|
public VendorRosterCompanyFieldsDTO? CompanyFields { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VendorRosterCompanyFieldsDTO
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,68 @@ namespace SeaHaven.Services.Implementation
|
||||||
return MapToDTO(saved);
|
return MapToDTO(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<VendorRosterDTO> AddTechniciansAsync(
|
||||||
|
int companyId,
|
||||||
|
AddTechniciansVendorRosterDTO dto,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated(userId);
|
||||||
|
dto.AddTechnicians ??= new List<RosterTechnicianInputDTO>();
|
||||||
|
|
||||||
|
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
|
||||||
|
|
||||||
|
var failures = new List<ValidationFailure>();
|
||||||
|
|
||||||
|
// Additive contract (SH-250): added technicians are always new rows, so an
|
||||||
|
// id from this or any other company is rejected instead of silently
|
||||||
|
// mutating an existing technician.
|
||||||
|
if (dto.AddTechnicians.Any(technician => technician.Id.HasValue))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
|
||||||
|
"Technician ids are not allowed when adding technicians."));
|
||||||
|
|
||||||
|
var hasCompanyChange = dto.CompanyFields != null && HasAnyCompanyValue(dto.CompanyFields);
|
||||||
|
if (dto.AddTechnicians.Count == 0 && !hasCompanyChange)
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
|
||||||
|
"At least one technician to add or a company update is required."));
|
||||||
|
|
||||||
|
NormalizeAndValidateTechnicians(nameof(AddTechniciansVendorRosterDTO.AddTechnicians), dto.AddTechnicians, failures);
|
||||||
|
|
||||||
|
VendorRosterCompanyFieldsWriteModel? companyFields = null;
|
||||||
|
if (dto.CompanyFields != null)
|
||||||
|
{
|
||||||
|
companyFields = ValidateAndMapCompanyFields(dto.CompanyFields, failures);
|
||||||
|
|
||||||
|
// SH-250: blank company fields all map to null ("unchanged"), so an empty
|
||||||
|
// or all-blank CompanyFields object carries no company update. Forwarding
|
||||||
|
// it as a non-null write model made the data layer bump RowVersion and
|
||||||
|
// rewrite every technician's LastModificationTime for a no-op request.
|
||||||
|
if (HasNoCompanyChange(companyFields))
|
||||||
|
companyFields = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.Count > 0)
|
||||||
|
throw new ValidationException(failures);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
var writeModel = new VendorCompanyRosterAddWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = rowVersion,
|
||||||
|
ActorUserId = userId,
|
||||||
|
CompanyFields = companyFields,
|
||||||
|
AddTechnicians = MapTechnicians(dto.AddTechnicians)
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await _rosterDataService.AddTechniciansAsync(writeModel, cancellationToken);
|
||||||
|
return MapToDTO(saved);
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureAuthenticated(string userId)
|
private static void EnsureAuthenticated(string userId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
|
@ -145,28 +207,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
"Google Maps URL must be an absolute HTTPS URL."));
|
"Google Maps URL must be an absolute HTTPS URL."));
|
||||||
|
|
||||||
// Technician phones must be valid North American format when provided.
|
// Technician phones must be valid North American format when provided.
|
||||||
for (var i = 0; i < technicians.Count; i++)
|
NormalizeAndValidateTechnicians(nameof(CreateVendorRosterDTO.Technicians), technicians, failures);
|
||||||
{
|
|
||||||
var technician = technicians[i];
|
|
||||||
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
|
|
||||||
technician.Phone = normalized;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
|
|
||||||
failures.Add(new ValidationFailure(
|
|
||||||
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
|
|
||||||
"Phone must be in North American format: (XXX) XXX-XXXX"));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
|
|
||||||
failures.Add(new ValidationFailure(
|
|
||||||
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
|
|
||||||
"Invalid email address."));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
|
|
||||||
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
|
|
||||||
failures.Add(new ValidationFailure(
|
|
||||||
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
|
|
||||||
"PreferredContact must be one of: Phone, Email, Text"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate technician ids are not allowed.
|
// Duplicate technician ids are not allowed.
|
||||||
var duplicateIds = technicians
|
var duplicateIds = technicians
|
||||||
|
|
@ -185,6 +226,102 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new ValidationException(failures);
|
throw new ValidationException(failures);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalizes technician phones in place and collects format failures using the
|
||||||
|
// supplied property-name prefix so each endpoint reports its own payload path.
|
||||||
|
private static void NormalizeAndValidateTechnicians(
|
||||||
|
string techniciansPropertyName,
|
||||||
|
List<RosterTechnicianInputDTO> technicians,
|
||||||
|
List<ValidationFailure> failures)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < technicians.Count; i++)
|
||||||
|
{
|
||||||
|
var technician = technicians[i];
|
||||||
|
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
|
||||||
|
technician.Phone = normalized;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
|
||||||
|
"Phone must be in North American format: (XXX) XXX-XXXX"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
|
||||||
|
"Invalid email address."));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
|
||||||
|
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
|
||||||
|
"PreferredContact must be one of: Phone, Email, Text"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates optional company-level updates for the additive path and maps them
|
||||||
|
// to the write model. A null (or blank) field means "leave unchanged"; blank
|
||||||
|
// values normalize to null so a partial update can never clear data by accident.
|
||||||
|
private static bool HasAnyCompanyValue(VendorRosterCompanyFieldsDTO fields) =>
|
||||||
|
!string.IsNullOrWhiteSpace(fields.Name)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.CompanyPhone)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.Email)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.Address)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.City)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.State)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.Zip)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|
||||||
|
|| !string.IsNullOrWhiteSpace(fields.Notes);
|
||||||
|
|
||||||
|
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
|
||||||
|
model.Name == null
|
||||||
|
&& model.CompanyPhone == null
|
||||||
|
&& model.Email == null
|
||||||
|
&& model.Address == null
|
||||||
|
&& model.City == null
|
||||||
|
&& model.State == null
|
||||||
|
&& model.Zip == null
|
||||||
|
&& model.GoogleMapsUrl == null
|
||||||
|
&& model.Notes == null;
|
||||||
|
|
||||||
|
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
|
||||||
|
VendorRosterCompanyFieldsDTO fields,
|
||||||
|
List<ValidationFailure> failures)
|
||||||
|
{
|
||||||
|
var name = fields.Name?.Trim();
|
||||||
|
if (fields.Name != null && string.IsNullOrWhiteSpace(name))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(VendorRosterCompanyFieldsDTO.Name),
|
||||||
|
"Company name is required."));
|
||||||
|
|
||||||
|
var normalizedPhone = VendorPhoneNormalizer.NormalizeToCanonical(fields.CompanyPhone);
|
||||||
|
if (normalizedPhone != null && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedPhone))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(VendorRosterCompanyFieldsDTO.CompanyPhone),
|
||||||
|
"Company phone must be in North American format: (XXX) XXX-XXXX"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(fields.Email) && !BeValidEmail(fields.Email))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(VendorRosterCompanyFieldsDTO.Email),
|
||||||
|
"Invalid email address."));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) && !BeValidAbsoluteHttpsUrl(fields.GoogleMapsUrl))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(VendorRosterCompanyFieldsDTO.GoogleMapsUrl),
|
||||||
|
"Google Maps URL must be an absolute HTTPS URL."));
|
||||||
|
|
||||||
|
return new VendorRosterCompanyFieldsWriteModel
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
CompanyPhone = normalizedPhone,
|
||||||
|
Email = string.IsNullOrWhiteSpace(fields.Email) ? null : fields.Email,
|
||||||
|
Address = string.IsNullOrWhiteSpace(fields.Address) ? null : fields.Address,
|
||||||
|
City = string.IsNullOrWhiteSpace(fields.City) ? null : fields.City,
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
|
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
|
||||||
int companyId,
|
int companyId,
|
||||||
List<RosterTechnicianInputDTO> technicians,
|
List<RosterTechnicianInputDTO> technicians,
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,11 @@ namespace SeaHaven.Services.Interfaces
|
||||||
ReconcileVendorRosterDTO dto,
|
ReconcileVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<VendorRosterDTO> AddTechniciansAsync(
|
||||||
|
int companyId,
|
||||||
|
AddTechniciansVendorRosterDTO dto,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue