mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge pull request #70 from Sea-Haven-Industries/feat/sh-250-roster-additive-patch
feat(vendor-roster): additive PATCH endpoint for technician adds (SH-250, SH-246)
This commit is contained in:
commit
e9669a06b5
10 changed files with 1019 additions and 24 deletions
|
|
@ -213,4 +213,75 @@ public class VendorCompanyRosterControllerTests
|
|||
serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
foreach (var name in technicianNames)
|
||||
|
|
@ -492,4 +497,322 @@ public class VendorCompanyRosterDataServiceTests
|
|||
tech.Zip.Should().Be("00005");
|
||||
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);
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
}
|
||||
|
||||
// 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.");
|
||||
}
|
||||
|
||||
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(
|
||||
IReadOnlyCollection<int> vendorIds,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -24,5 +24,13 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<VendorCompanyRosterReadModel> CreateRosterAsync(
|
||||
VendorCompanyRosterWriteModel roster,
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
/// <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 IReadOnlyList<LinkedWorkOrderInfo> BlockedWorkOrders { get; }
|
||||
|
|
|
|||
|
|
@ -67,4 +67,27 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Notes { get; set; }
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
|
|
@ -145,28 +207,7 @@ namespace SeaHaven.Services.Implementation
|
|||
"Google Maps URL must be an absolute HTTPS URL."));
|
||||
|
||||
// Technician phones must be valid North American format when provided.
|
||||
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(
|
||||
$"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"));
|
||||
}
|
||||
NormalizeAndValidateTechnicians(nameof(CreateVendorRosterDTO.Technicians), technicians, failures);
|
||||
|
||||
// Duplicate technician ids are not allowed.
|
||||
var duplicateIds = technicians
|
||||
|
|
@ -185,6 +226,102 @@ namespace SeaHaven.Services.Implementation
|
|||
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(
|
||||
int companyId,
|
||||
List<RosterTechnicianInputDTO> technicians,
|
||||
|
|
|
|||
|
|
@ -20,5 +20,11 @@ namespace SeaHaven.Services.Interfaces
|
|||
ReconcileVendorRosterDTO dto,
|
||||
string userId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<VendorRosterDTO> AddTechniciansAsync(
|
||||
int companyId,
|
||||
AddTechniciansVendorRosterDTO dto,
|
||||
string userId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue