From 669e9b2932fcada124fddcab6dc7849c6aea8386 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 3 Aug 2026 17:53:24 -0300 Subject: [PATCH] feat(vendors): add company roster management (SH-198) (#48) * feat(vendors): add company roster management * fix(security): remove request-controlled write guards * fix(vendors): synchronize roster company fields * fix(vendors): source facets from companies --- .../VendorCompanyRosterControllerTests.cs | 216 + .../VendorCompanyRosterDataServiceTests.cs | 495 +++ .../VendorCompanyRosterServiceTests.cs | 383 ++ .../VendorControllerTests.cs | 32 + .../VendorDataServiceTests.cs | 173 + .../VendorServiceTests.cs | 176 +- .../VendorCompanyRosterController.cs | 143 + .../Controllers/VendorController.cs | 10 +- .../Auth/ApplicationDbContext.cs | 10 + ...endorCompanyNotesAndRowVersion.Designer.cs | 3787 +++++++++++++++++ ...7_SH198_VendorCompanyNotesAndRowVersion.cs | 67 + .../ApplicationDbContextModelSnapshot.cs | 8 + .../Models/VendorCompany.cs | 5 + .../VendorCompanyRosterDataService.cs | 320 ++ .../Implementation/VendorDataService.cs | 240 +- .../IVendorCompanyRosterDataService.cs | 28 + .../Interfaces/IVendorDataService.cs | 22 + .../Models/VendorCompanyRosterModels.cs | 74 + .../Models/VendorDirectoryReadModels.cs | 23 + .../DTOs/VendorCompanyRosterDTOs.cs | 70 + SeaHaven.Services/DTOs/VendorDTOs.cs | 23 + .../VendorCompanyRosterService.cs | 288 ++ .../Implementation/VendorService.cs | 101 +- .../Interfaces/IVendorCompanyRosterService.cs | 24 + .../Interfaces/IVendorService.cs | 12 + .../Validation/VendorValidation.cs | 11 +- 26 files changed, 6698 insertions(+), 43 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.cs create mode 100644 SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs create mode 100644 SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs create mode 100644 SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs create mode 100644 SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs create mode 100644 SeaHaven.Services/Implementation/VendorCompanyRosterService.cs create mode 100644 SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs new file mode 100644 index 0000000..2ee5d82 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs @@ -0,0 +1,216 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorCompanyRosterControllerTests +{ + private static VendorCompanyRosterController NewController(Mock service, string? userId = "42") + { + var controller = new VendorCompanyRosterController(service.Object, Mock.Of>()); + + ClaimsIdentity identity; + if (userId == null) + identity = new ClaimsIdentity(); + else + identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "Test"); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + + return controller; + } + + private static VendorRosterDTO SampleRoster() => new() + { + CompanyId = 7, + Name = "Acme", + Email = "acme@example.com", + RowVersion = "AAAAAAAAD8I=", + Technicians = new List { new() { Id = 1, ContactName = "Riley" } } + }; + + [Fact] + public async Task Get_Unauthenticated_Returns401() + { + var service = new Mock(); + var controller = NewController(service, userId: null); + + var result = await controller.Get(vendorId: 1, companyId: null, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Get_RosterFound_Returns200() + { + var service = new Mock(); + service.Setup(x => x.GetRosterAsync(1, null, "42", It.IsAny())) + .ReturnsAsync(SampleRoster()); + + var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.StatusCode.Should().Be(StatusCodes.Status200OK); + ok.Value.Should().BeEquivalentTo(SampleRoster()); + } + + [Fact] + public async Task Get_RosterMissing_Returns404() + { + var service = new Mock(); + service.Setup(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ReturnsAsync((VendorRosterDTO?)null); + + var result = await NewController(service).Get(vendorId: 99, companyId: null, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Get_ValidationException_Returns400() + { + var service = new Mock(); + service.Setup(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new ValidationException("A vendor id or company id is required.")); + + var result = await NewController(service).Get(vendorId: null, companyId: null, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Create_Unauthenticated_Returns401() + { + var service = new Mock(); + var controller = NewController(service, userId: null); + + var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_Returns200() + { + var service = new Mock(); + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + .ReturnsAsync(SampleRoster()); + + var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [Fact] + public async Task Create_ValidationException_Returns400() + { + var service = new Mock(); + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new ValidationException("At least one company phone or email is required.")); + + var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Reconcile_Unauthenticated_Returns401() + { + var service = new Mock(); + var controller = NewController(service, userId: null); + + var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reconcile_ValidationException_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed.")); + + var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Reconcile_CompanyNotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("not found")); + + var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Reconcile_OpenWorkOrderConflict_ReturnsStable409() + { + var blocked = new List + { + new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled } + }; + var service = new Mock(); + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new VendorRosterConflictException("blocked", blocked)); + + var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + } + + [Fact] + public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText() + { + var service = new Mock(); + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); + + var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); + } + + [Fact] + public async Task Get_UnexpectedException_ReturnsSanitized500() + { + var service = new Mock(); + service.Setup(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("secret stack details")); + + var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None); + + var serverError = result.Should().BeOfType().Subject; + serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + serverError.Value!.ToString()!.Should().NotContain("secret stack details"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs new file mode 100644 index 0000000..d0f4bf4 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs @@ -0,0 +1,495 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Models; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorCompanyRosterDataServiceTests +{ + private static ApplicationDbContext NewContext(string dbName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(ApplicationDbContext context, string companyName, params string[] technicianNames) + { + var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant() }; + context.VendorCompanies.Add(company); + + foreach (var name in technicianNames) + { + context.Vendors.Add(new Vendor + { + CompanyName = companyName, + ContactName = name, + IsActive = true, + IsDeleted = false, + Company = company + }); + } + + await context.SaveChangesAsync(); + return (company.Id, context.Vendors.First(v => v.CompanyId == company.Id)); + } + + [Fact] + public async Task CreateRoster_PersistsCompanyAndTechniciansAtomically() + { + var dbName = Guid.NewGuid().ToString(); + using var context = NewContext(dbName); + var service = new VendorCompanyRosterDataService(context); + + var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel + { + Name = "Atomic Co", + Email = "atomic@example.com", + Notes = "shared notes", + ActorUserId = "42", + Technicians = new List + { + new() { ContactName = "T1" }, + new() { ContactName = "T2" } + } + }, CancellationToken.None); + + roster.CompanyId.Should().BeGreaterThan(0); + roster.Technicians.Should().HaveCount(2); + roster.Notes.Should().Be("shared notes"); + + var company = context.VendorCompanies.Single(c => c.Id == roster.CompanyId); + company.Name.Should().Be("Atomic Co"); + context.Vendors.Count(v => v.CompanyId == company.Id).Should().Be(2); + context.Vendors.Where(v => v.CompanyId == company.Id).Should().OnlyContain(v => v.CompanyName == "Atomic Co"); + } + + [Fact] + public async Task GetRoster_ByVendorId_ResolvesCompanyAndAllTechnicians() + { + var dbName = Guid.NewGuid().ToString(); + int companyId, vendorId; + using (var seed = NewContext(dbName)) + { + var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Resolve Co", "A", "B"); + companyId = cid; + vendorId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "A").Id; + } + + using var context = NewContext(dbName); + var service = new VendorCompanyRosterDataService(context); + + var roster = await service.GetRosterAsync(vendorId, null, CancellationToken.None); + + roster.Should().NotBeNull(); + roster!.CompanyId.Should().Be(companyId); + roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "A", "B" }); + } + + [Fact] + public async Task GetRoster_WithMismatchedVendorAndCompanyIds_ReturnsNull() + { + var dbName = Guid.NewGuid().ToString(); + int firstCompanyId, secondVendorId; + using (var seed = NewContext(dbName)) + { + var (firstId, _) = await SeedCompanyWithTechnicianAsync(seed, "First Co", "A"); + var (_, secondVendor) = await SeedCompanyWithTechnicianAsync(seed, "Second Co", "B"); + firstCompanyId = firstId; + secondVendorId = secondVendor.Id; + } + + using var context = NewContext(dbName); + var service = new VendorCompanyRosterDataService(context); + + var roster = await service.GetRosterAsync(secondVendorId, firstCompanyId, CancellationToken.None); + + roster.Should().BeNull(); + } + + [Fact] + public async Task SaveRoster_RenamesCompanyAndTechnicianAndUpdatesNotes() + { + var dbName = Guid.NewGuid().ToString(); + int companyId, techId; + using (var seed = NewContext(dbName)) + { + var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Old Co", "Old Name"); + companyId = cid; + techId = vendor.Id; + } + + using var context = NewContext(dbName); + var service = new VendorCompanyRosterDataService(context); + + var roster = await service.SaveRosterAsync(new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + Name = "New Co", + Email = "new@example.com", + Notes = "updated notes", + ActorUserId = "42", + Technicians = new List + { + new() { Id = techId, ContactName = "New Name", IsActive = true } + } + }, CancellationToken.None); + + roster.Name.Should().Be("New Co"); + roster.Notes.Should().Be("updated notes"); + var tech = context.Vendors.Single(v => v.Id == techId); + tech.ContactName.Should().Be("New Name"); + tech.CompanyName.Should().Be("New Co"); + context.VendorCompanies.Single(c => c.Id == companyId).Notes.Should().Be("updated notes"); + } + + [Fact] + public async Task SaveRoster_SoftDeletesOmittedTechnicianWithoutOpenWorkOrders() + { + var dbName = Guid.NewGuid().ToString(); + int companyId, keepId, dropId; + using (var seed = NewContext(dbName)) + { + var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Drop Co", "Keep", "Drop"); + companyId = cid; + keepId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Keep").Id; + dropId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Drop").Id; + } + + using (var act = NewContext(dbName)) + { + var service = new VendorCompanyRosterDataService(act); + await service.SaveRosterAsync(new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + Name = "Drop Co", + Email = "drop@example.com", + ActorUserId = "42", + Technicians = new List + { + new() { Id = keepId, ContactName = "Keep", IsActive = true } + } + }, CancellationToken.None); + } + + using var verify = NewContext(dbName); + var kept = verify.Vendors.Single(v => v.Id == keepId); + kept.IsDeleted.Should().NotBeTrue(); + var dropped = verify.Vendors.Single(v => v.Id == dropId); + dropped.IsDeleted.Should().BeTrue(); + dropped.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task SaveRoster_RemovingAllTechniciansLeavesZeroVendorsAndCompanyRemains() + { + var dbName = Guid.NewGuid().ToString(); + int companyId; + using (var seed = NewContext(dbName)) + { + var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Empty Co", "T1", "T2"); + companyId = cid; + } + + using (var act = NewContext(dbName)) + { + var service = new VendorCompanyRosterDataService(act); + await service.SaveRosterAsync(new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + Name = "Empty Co", + Email = "empty@example.com", + ActorUserId = "42", + Technicians = new List() + }, CancellationToken.None); + } + + using var verify = NewContext(dbName); + verify.Vendors.Count(v => v.CompanyId == companyId && (v.IsDeleted == null || v.IsDeleted == false)).Should().Be(0); + verify.VendorCompanies.Any(c => c.Id == companyId).Should().BeTrue(); + } + + [Fact] + public async Task SaveRoster_RemovalBlockedByOpenWorkOrdersFailsWholeSave() + { + var dbName = Guid.NewGuid().ToString(); + int companyId, vendorId; + using (var seed = NewContext(dbName)) + { + var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Blocked Co", "Busy"); + companyId = cid; + vendorId = vendor.Id; + seed.workOrders.Add(new WorkOrder { Id = 800, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled" }); + seed.Dispatches.Add(new Dispatch { VendorId = vendorId, WorkOrderId = 800, Vendor = vendor }); + await seed.SaveChangesAsync(); + } + + using (var act = NewContext(dbName)) + { + var service = new VendorCompanyRosterDataService(act); + var actCall = () => service.SaveRosterAsync(new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }, + Name = "Blocked Co", + Email = "blocked@example.com", + ActorUserId = "42", + Technicians = new List() + }, CancellationToken.None); + + await actCall.Should().ThrowAsync(); + } + + using var verify = NewContext(dbName); + var tech = verify.Vendors.Single(v => v.Id == vendorId); + tech.IsDeleted.Should().NotBeTrue(); + tech.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task GetRoster_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.GetRosterAsync(1, null, cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveRoster_PropagatesCompanyLocationAndPhoneToRetainedTechnician() + { + var dbName = Guid.NewGuid().ToString(); + int companyId, techId; + using (var seed = NewContext(dbName)) + { + var company = new VendorCompany + { + Name = "Loc Co", + NormalizedName = "loc co", + CompanyPhone = "(111) 111-0001", + Address = "Old Addr", + City = "OldCity", + State = "OS", + Zip = "00001", + GoogleMapsUrl = "https://old.example/map" + }; + seed.VendorCompanies.Add(company); + seed.Vendors.Add(new Vendor + { + CompanyName = "Loc Co", + ContactName = "Keeper", + IsActive = true, + IsDeleted = false, + Company = company, + CompanyPhone = "(111) 111-0001", + Address = "Old Addr", + City = "OldCity", + State = "OS", + Zip = "00001", + GoogleMapsUrl = "https://old.example/map" + }); + await seed.SaveChangesAsync(); + companyId = company.Id; + techId = seed.Vendors.First(v => v.CompanyId == companyId).Id; + } + + using (var act = NewContext(dbName)) + { + var service = new VendorCompanyRosterDataService(act); + await service.SaveRosterAsync(new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + Name = "Loc Co", + CompanyPhone = "(222) 222-0002", + Address = "New Addr", + City = "NewCity", + State = "NS", + Zip = "00002", + GoogleMapsUrl = "https://new.example/map", + ActorUserId = "42", + Technicians = new List + { + new() { Id = techId, ContactName = "Keeper", IsActive = true } + } + }, CancellationToken.None); + } + + using var verify = NewContext(dbName); + var tech = verify.Vendors.Single(v => v.Id == techId); + tech.CompanyPhone.Should().Be("(222) 222-0002"); + tech.Address.Should().Be("New Addr"); + tech.City.Should().Be("NewCity"); + tech.State.Should().Be("NS"); + tech.Zip.Should().Be("00002"); + tech.GoogleMapsUrl.Should().Be("https://new.example/map"); + + var directory = new VendorDataService(verify); + var newCity = await directory.GetDirectoryPagedAsync(1, 10, search: "NewCity"); + newCity.Items.Should().ContainSingle(row => row.VendorId == techId); + + var oldCity = await directory.GetDirectoryPagedAsync(1, 10, search: "OldCity"); + oldCity.Items.Should().NotContain(row => row.VendorId == techId); + } + + [Fact] + public async Task SaveRoster_ClearsCompanyOwnedFieldsOnRetainedTechnicianWhenCompanyClearsThem() + { + var dbName = Guid.NewGuid().ToString(); + int companyId, techId; + using (var seed = NewContext(dbName)) + { + var company = new VendorCompany + { + Name = "Clear Co", + NormalizedName = "clear co", + CompanyPhone = "(333) 333-0003", + Address = "Keep Addr", + City = "KeepCity", + State = "KS", + Zip = "00003", + GoogleMapsUrl = "https://keep.example/map" + }; + seed.VendorCompanies.Add(company); + seed.Vendors.Add(new Vendor + { + CompanyName = "Clear Co", + ContactName = "Keeper", + IsActive = true, + IsDeleted = false, + Company = company, + CompanyPhone = "(333) 333-0003", + Address = "Keep Addr", + City = "KeepCity", + State = "KS", + Zip = "00003", + GoogleMapsUrl = "https://keep.example/map" + }); + await seed.SaveChangesAsync(); + companyId = company.Id; + techId = seed.Vendors.First(v => v.CompanyId == companyId).Id; + } + + using (var act = NewContext(dbName)) + { + var service = new VendorCompanyRosterDataService(act); + await service.SaveRosterAsync(new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + Name = "Clear Co", + CompanyPhone = null, + Address = null, + City = null, + State = null, + Zip = null, + GoogleMapsUrl = null, + ActorUserId = "42", + Technicians = new List + { + new() { Id = techId, ContactName = "Keeper", IsActive = true } + } + }, CancellationToken.None); + } + + using var verify = NewContext(dbName); + var tech = verify.Vendors.Single(v => v.Id == techId); + tech.CompanyPhone.Should().BeNull(); + tech.Address.Should().BeNull(); + tech.City.Should().BeNull(); + tech.State.Should().BeNull(); + tech.Zip.Should().BeNull(); + tech.GoogleMapsUrl.Should().BeNull(); + } + + [Fact] + public async Task SaveRoster_NewTechnicianReceivesCompanyPhoneAndLocation() + { + var dbName = Guid.NewGuid().ToString(); + int companyId; + using (var seed = NewContext(dbName)) + { + var company = new VendorCompany + { + Name = "Add Co", + NormalizedName = "add co" + }; + seed.VendorCompanies.Add(company); + await seed.SaveChangesAsync(); + companyId = company.Id; + } + + int newTechId; + using (var act = NewContext(dbName)) + { + var service = new VendorCompanyRosterDataService(act); + await service.SaveRosterAsync(new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + Name = "Add Co", + CompanyPhone = "(444) 444-0004", + Address = "Add Addr", + City = "AddCity", + State = "AS", + Zip = "00004", + GoogleMapsUrl = "https://add.example/map", + ActorUserId = "42", + Technicians = new List + { + new() { ContactName = "Fresh", IsActive = true } + } + }, CancellationToken.None); + newTechId = act.Vendors.First(v => v.CompanyId == companyId && v.ContactName == "Fresh").Id; + } + + using var verify = NewContext(dbName); + var tech = verify.Vendors.Single(v => v.Id == newTechId); + tech.CompanyPhone.Should().Be("(444) 444-0004"); + tech.Address.Should().Be("Add Addr"); + tech.City.Should().Be("AddCity"); + tech.State.Should().Be("AS"); + tech.Zip.Should().Be("00004"); + tech.GoogleMapsUrl.Should().Be("https://add.example/map"); + } + + [Fact] + public async Task CreateRoster_NewTechnicianReceivesCompanyPhoneAndLocation() + { + var dbName = Guid.NewGuid().ToString(); + using var context = NewContext(dbName); + var service = new VendorCompanyRosterDataService(context); + + var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel + { + Name = "Create Co", + CompanyPhone = "(555) 555-0005", + Address = "Create Addr", + City = "CreateCity", + State = "CS", + Zip = "00005", + GoogleMapsUrl = "https://create.example/map", + ActorUserId = "42", + Technicians = new List + { + new() { ContactName = "Brand", IsActive = true } + } + }, CancellationToken.None); + + var tech = context.Vendors.Single(v => v.CompanyId == roster.CompanyId); + tech.CompanyPhone.Should().Be("(555) 555-0005"); + tech.Address.Should().Be("Create Addr"); + tech.City.Should().Be("CreateCity"); + tech.State.Should().Be("CS"); + tech.Zip.Should().Be("00005"); + tech.GoogleMapsUrl.Should().Be("https://create.example/map"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs new file mode 100644 index 0000000..fdbb3c9 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs @@ -0,0 +1,383 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorCompanyRosterServiceTests +{ + private static VendorCompanyRosterService NewService(Mock data) => new(data.Object); + + private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new() + { + CompanyId = companyId, + Name = "Acme", + CompanyPhone = "(312) 555-0199", + Email = "acme@example.com", + RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }, + Technicians = technicianIds.Select(id => new VendorRosterTechnicianReadModel { Id = id, ContactName = $"Tech {id}", IsActive = true }).ToList() + }; + + [Fact] + public async Task Get_WithoutAuthenticatedUser_Throws() + { + var data = new Mock(); + var act = () => NewService(data).GetRosterAsync(1, null, "", CancellationToken.None); + await act.Should().ThrowAsync(); + data.Verify(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Get_WithoutVendorOrCompanyId_ThrowsValidation() + { + var data = new Mock(); + var act = () => NewService(data).GetRosterAsync(null, null, "42", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Create_WithoutCompanyPhoneOrEmail_ThrowsContactGroupValidation() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO { Name = "Acme" }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_InvalidCompanyPhone_DoesNotSatisfyContactGroup() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone)); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_RejectsExistingTechnicianIds() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + Technicians = new List { new() { Id = 7, ContactName = "Riley" } } + }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AcceptsZeroTechniciansWhenContactGroupPresent() + { + var data = new Mock(); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel()); + + await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + Technicians = new List() + }, "42", CancellationToken.None); + + captured.Should().NotBeNull(); + captured!.Technicians.Should().BeEmpty(); + captured.Name.Should().Be("Acme"); + } + + [Fact] + public async Task Reconcile_RejectsDuplicateTechnicianIds() + { + var data = new Mock(); + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + Technicians = new List + { + new() { Id = 5, ContactName = "Dup" }, + new() { Id = 5, ContactName = "Dup Again" } + } + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reconcile_RejectsMismatchedTechnicianIds() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(SampleReadModel(7, 1, 2)); + + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + Technicians = new List + { + new() { Id = 1, ContactName = "Ok" }, + new() { Id = 999, ContactName = "Foreign" } + } + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_RejectsNonHttpsMapUrl() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + GoogleMapsUrl = "http://maps.google.com/acme" + }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl)); + } + + [Fact] + public async Task Create_NormalizesTechnicianPhoneBeforeForwarding() + { + var data = new Mock(); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel()); + + await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + Technicians = new List + { + new() { ContactName = "Riley", Phone = "+1 312 555 0100" } + } + }, "42", CancellationToken.None); + + captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100"); + } + + [Fact] + public async Task Create_RejectsInvalidTechnicianPhoneFormat() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + Technicians = new List + { + new() { ContactName = "Riley", Phone = "555-1234" } + } + }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); + } + + [Fact] + public async Task Reconcile_ParsesRowVersionAndForwardsCompanyId() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(SampleReadModel(7, 1)); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel(7, 1)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Co", + Email = "acme@example.com", + Technicians = new List { new() { Id = 1, ContactName = "T" } } + }, "42", CancellationToken.None); + + captured.Should().NotBeNull(); + captured!.CompanyId.Should().Be(7); + captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I=")); + captured.Name.Should().Be("Acme Co"); + } + + [Fact] + public async Task Reconcile_RejectsMalformedRowVersion() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(SampleReadModel(7)); + + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "not-valid-base64!!", + Name = "Acme", + Email = "acme@example.com" + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reconcile_RejectsMissingRowVersion() + { + var data = new Mock(); + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "", + Name = "Acme", + Email = "acme@example.com" + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_NullTechnicians_IsTreatedAsEmptyRoster() + { + var data = new Mock(); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel()); + var dto = new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + Technicians = null! + }; + + await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + + captured!.Technicians.Should().BeEmpty(); + } + + [Theory] + [InlineData("Riley", "not-an-email", "Phone")] + [InlineData("Riley", "tech@example.com", "CarrierPigeon")] + public async Task Create_RejectsInvalidTechnicianFields(string name, string email, string preferredContact) + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + Technicians = new List + { + new() { ContactName = name, Email = email, PreferredContact = preferredContact } + } + }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AcceptsTechnicianWithoutNameOrPhone() + { + var data = new Mock(); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel()); + + await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO + { + Name = "Acme", + Email = "acme@example.com", + Technicians = new List + { + new() { ContactName = "", Phone = "", PreferredContact = "Phone" } + } + }, "42", CancellationToken.None); + + captured!.Technicians.Should().ContainSingle(); + captured.Technicians.Single().ContactName.Should().BeEmpty(); + captured.Technicians.Single().Phone.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task Reconcile_UnknownCompanyWithTechnicianIds_ThrowsValidation() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 404, It.IsAny())) + .ReturnsAsync((VendorCompanyRosterReadModel?)null); + + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + Technicians = new List { new() { Id = 1, ContactName = "T" } } + }; + + var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reconcile_ForwardsCancellation() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(SampleReadModel(7, 1)); + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SampleReadModel(7, 1)); + + using var cts = new CancellationTokenSource(); + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + Technicians = new List { new() { Id = 1, ContactName = "T" } } + }, "42", cts.Token); + + data.Verify(x => x.SaveRosterAsync(It.IsAny(), cts.Token), Times.Once); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index e7652b8..24477c4 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -33,6 +33,38 @@ public class VendorControllerTests return controller; } + [Fact] + public async Task GetVendorList_ForwardsRequestCancellationToken() + { + var service = new Mock(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + service.Setup(x => x.GetVendorDirectoryPagedAsync( + 1, + 10, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token)) + .ReturnsAsync(new PagedResult()); + + await NewController(service).GetVendorList(search: "clayton", cancellationToken: token); + + service.Verify(x => x.GetVendorDirectoryPagedAsync( + 1, + 10, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token), Times.Once); + } + [Fact] public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus() { diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 482e4bb..634443a 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -3,6 +3,7 @@ using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Models; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -321,4 +322,176 @@ public class VendorDataServiceTests result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 }); result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems(); } + + [Fact] + public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows() + { + await using var context = NewContext(); + var soloCompany = new VendorCompany { Name = "Solo Co", NormalizedName = "solo co", Notes = "company notes" }; + var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" }; + context.VendorCompanies.AddRange(soloCompany, techCompany); + context.Vendors.AddRange( + new Vendor + { + CompanyName = "Tech Co", + ContactName = "Pat", + City = "Austin", + State = "TX", + IsActive = true, + IsDeleted = false, + Company = techCompany + }, + new Vendor + { + CompanyName = "Other", + ContactName = "No Company Tech", + IsActive = true, + IsDeleted = false, + CompanyId = null + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var (items, totalCount) = await service.GetDirectoryPagedAsync(1, 50, isActive: true); + + totalCount.Should().Be(3); + var companyOnly = items.Single(i => i.CompanyName == "Solo Co"); + companyOnly.VendorId.Should().BeNull(); + companyOnly.CompanyId.Should().Be(soloCompany.Id); + companyOnly.Notes.Should().Be("company notes"); + + items.Should().Contain(i => i.ContactName == "Pat" && i.VendorId != null && i.CompanyId == techCompany.Id); + items.Should().Contain(i => i.ContactName == "No Company Tech" && i.VendorId != null); + } + + [Theory] + [InlineData("trade")] + [InlineData("location")] + [InlineData("job-bucket")] + public void BuildDirectoryUnionQuery_TranslatesToSqlServerUnion(string filter) + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var query = filter switch + { + "trade" => service.BuildDirectoryUnionQuery(trades: new[] { "Plumbing" }), + "location" => service.BuildDirectoryUnionQuery(locations: new[] { "Chicago, IL" }), + _ => service.BuildDirectoryUnionQuery(jobBuckets: new[] { "under-50" }) + }; + + var sql = query.ToQueryString(); + + sql.Should().Contain("SELECT"); + sql.Should().Contain("UNION"); + if (filter == "trade") + sql.Should().Contain("TradeSpecialties"); + if (filter == "location") + sql.Should().Contain("City"); + if (filter == "job-bucket") + sql.Should().Contain("TotalJobs"); + } + + [Fact] + public async Task GetCompaniesForFacetsAsync_IncludesZeroTechnicianCompanyForActiveAndNullStatus() + { + await using var context = NewContext(); + var rosterService = new VendorCompanyRosterDataService(context); + await rosterService.CreateRosterAsync(new VendorCompanyRosterWriteModel + { + Name = "Solo Co", + Email = "solo@example.com", + ActorUserId = "42", + Technicians = new List() + }, CancellationToken.None); + + var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" }; + context.VendorCompanies.Add(techCompany); + context.Vendors.Add(new Vendor + { + CompanyName = "Tech Co", + ContactName = "Pat", + IsActive = true, + IsDeleted = false, + Company = techCompany + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None); + active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" }); + + var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None); + noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" }); + + var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None); + inactive.Should().BeEmpty(); + } + + [Fact] + public async Task GetCompaniesForFacetsAsync_InactiveStatusReturnsOnlyCompaniesWithInactiveTechnicians() + { + await using var context = NewContext(); + var activeOnlyCo = new VendorCompany { Name = "Active Only Co", NormalizedName = "active only co" }; + var inactiveOnlyCo = new VendorCompany { Name = "Inactive Only Co", NormalizedName = "inactive only co" }; + var mixedCo = new VendorCompany { Name = "Mixed Co", NormalizedName = "mixed co" }; + context.VendorCompanies.AddRange(activeOnlyCo, inactiveOnlyCo, mixedCo); + context.Vendors.AddRange( + new Vendor { CompanyName = "Active Only Co", IsActive = true, IsDeleted = false, Company = activeOnlyCo }, + new Vendor { CompanyName = "Inactive Only Co", IsActive = false, IsDeleted = false, Company = inactiveOnlyCo }, + new Vendor { CompanyName = "Mixed Co", IsActive = true, IsDeleted = false, Company = mixedCo }, + new Vendor { CompanyName = "Mixed Co", IsActive = false, IsDeleted = false, Company = mixedCo }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None); + inactive.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Inactive Only Co", "Mixed Co" }); + + var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None); + active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Mixed Co" }); + + var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None); + noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Inactive Only Co", "Mixed Co" }); + } + + [Fact] + public async Task GetCompaniesForFacetsAsync_TreatsCompanyWithOnlyDeletedTechniciansAsZeroTechnician() + { + await using var context = NewContext(); + var company = new VendorCompany { Name = "Ghost Co", NormalizedName = "ghost co" }; + context.VendorCompanies.Add(company); + context.Vendors.Add(new Vendor + { + CompanyName = "Ghost Co", + IsActive = true, + IsDeleted = true, + Company = company + }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + + var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None); + active.Single().Name.Should().Be("Ghost Co"); + + var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None); + inactive.Should().BeEmpty(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public void BuildCompaniesForFacetsQuery_TranslatesToSqlServerWithoutClientEvaluation(bool? isActive) + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var sql = service.BuildCompaniesForFacetsQuery(isActive).ToQueryString(); + + sql.Should().Contain("SELECT"); + sql.Should().Contain("VendorCompanies"); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index cc55139..2823fa6 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -5,6 +5,7 @@ using FluentValidation; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; @@ -146,6 +147,43 @@ public class VendorServiceTests }, options => options.ExcludingMissingMembers()); } + [Fact] + public async Task GetVendorDirectoryPaged_ForwardsCancellationToken() + { + var data = new Mock(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + data.Setup(x => x.GetDirectoryPagedAsync( + 1, + 12, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token)) + .ReturnsAsync((Array.Empty(), 0)); + + await NewService(data).GetVendorDirectoryPagedAsync( + 1, + 12, + "clayton", + true, + cancellationToken: token); + + data.Verify(x => x.GetDirectoryPagedAsync( + 1, + 12, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token), Times.Once); + } + [Fact] public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() { @@ -209,20 +247,44 @@ public class VendorServiceTests } [Fact] - public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl() + public void CreateVendor_AcceptsOptionalTechnicianButRequiresContactGroupAndHttpsUrl() { var validator = new CreateVendorValidation(); - var result = validator.Validate(new CreateVendorDTO + // SH-199: technician (ContactName) is optional; zero-technician create is valid + // as long as a company phone or email is present and the map URL is HTTPS. + var valid = validator.Validate(new CreateVendorDTO { Name = "Gateway Plumbing", ContactName = "", + Email = "dispatch@gateway.example", + GoogleMapsUrl = "https://maps.google.com/gateway" + }); + + valid.IsValid.Should().BeTrue(); + valid.Errors.Should().NotContain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); + + // Missing both company phone and email fails the contact-group rule. + var noContact = validator.Validate(new CreateVendorDTO + { + Name = "Gateway Plumbing", + ContactName = "", + GoogleMapsUrl = "https://maps.google.com/gateway" + }); + + noContact.IsValid.Should().BeFalse(); + noContact.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.CompanyPhone)); + + // Non-HTTPS map URL still rejected. + var badUrl = validator.Validate(new CreateVendorDTO + { + Name = "Gateway Plumbing", + Email = "dispatch@gateway.example", GoogleMapsUrl = "http://maps.google.com/gateway" }); - result.IsValid.Should().BeFalse(); - result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); - result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); + badUrl.IsValid.Should().BeFalse(); + badUrl.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); } [Fact] @@ -283,7 +345,8 @@ public class VendorServiceTests { Name = "Acme", CompanyId = 77, - ContactName = "Riley" + ContactName = "Riley", + Email = "acme@example.com" }, "42"); saved.Should().NotBeNull(); @@ -327,7 +390,8 @@ public class VendorServiceTests { Name = "Acme", ContactName = "Riley", - Phone = "+1 312 555 0100" + Phone = "+1 312 555 0100", + Email = "acme@example.com" }, "42"); saved!.Phone.Should().Be("(312) 555-0100"); @@ -488,4 +552,102 @@ public class VendorServiceTests .WithMessage("*404*not found"); data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny()), Times.Never); } + + [Fact] + public async Task GetFacetsAsync_CompaniesAndLocationsUseCompanyOwnedFieldsOverTechnicianFields() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Id = 1, + Name = "Owner Co", + CompanyPhone = "(555) 000-0001", + Email = "owner@example.com", + Address = "Owner Addr", + City = "OwnerCity", + State = "OS", + Zip = "00001", + GoogleMapsUrl = "https://owner.example/map" + } + }); + data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Id = 10, + CompanyName = "Owner Co", + ContactName = "Pat", + City = "TechCity", + State = "TS", + Email = "tech@example.com", + CompanyPhone = "(555) 000-9999", + TradeSpecialties = "Plumbing" + } + }); + + var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); + + var company = facets.Companies.Single(); + company.Name.Should().Be("Owner Co"); + company.CompanyId.Should().Be(1); + company.CompanyPhone.Should().Be("(555) 000-0001"); + company.Email.Should().Be("owner@example.com"); + company.Address.Should().Be("Owner Addr"); + company.City.Should().Be("OwnerCity"); + company.State.Should().Be("OS"); + company.Zip.Should().Be("00001"); + company.GoogleMapsUrl.Should().Be("https://owner.example/map"); + company.PreferredContact.Should().BeNull(); + + var location = facets.Locations.Single(); + location.City.Should().Be("OwnerCity"); + location.State.Should().Be("OS"); + location.Label.Should().Be("OwnerCity, OS"); + + facets.Trades.Should().BeEquivalentTo(new[] { "Plumbing" }); + } + + [Fact] + public async Task GetFacetsAsync_CompaniesAreUniqueAndOrderedCaseInsensitively() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "Beta Co" }, + new() { Id = 2, Name = "alpha co" }, + new() { Id = 3, Name = "Alpha Co" } + }); + data.Setup(x => x.GetVendorsForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List()); + + var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); + + // Case-insensitive dedup collapses "alpha co"/"Alpha Co" to one entry, and + // ordering places "alpha co" before "Beta Co" (case-insensitive, not Ordinal). + facets.Companies.Select(c => c.Name).Should().BeEquivalentTo( + new[] { "alpha co", "Beta Co" }, + opts => opts.WithStrictOrdering()); + } + + [Fact] + public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies() + { + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Name = "No Trade Co" } }); + data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 20, CompanyName = "Trade Co", TradeSpecialties = "HVAC, Roofing" } + }); + + var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); + + facets.Trades.Should().BeEquivalentTo(new[] { "HVAC", "Roofing" }, opts => opts.WithStrictOrdering()); + } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs new file mode 100644 index 0000000..ec5fbca --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs @@ -0,0 +1,143 @@ +using Api.SeaHavenIndustries.Helper; +using Data.SeaHavenIndustries; +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/vendor-company-roster")] + public class VendorCompanyRosterController : Controller + { + private readonly IVendorCompanyRosterService _rosterService; + private readonly ILogger _logger; + + public VendorCompanyRosterController( + IVendorCompanyRosterService rosterService, + ILogger logger) + { + _rosterService = rosterService; + _logger = logger; + } + + [HttpGet] + public async Task Get( + [FromQuery] int? vendorId, + [FromQuery] int? companyId, + 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.GetRosterAsync(vendorId, companyId, userId, cancellationToken); + if (roster == null) + return NotFound(new Response { Status = "Error", Message = "Vendor roster not found" }); + + 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 (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost] + public async Task Create([FromBody] CreateVendorRosterDTO 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.CreateRosterAsync(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 (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPut("{companyId:int}")] + public async Task Reconcile( + int companyId, + [FromBody] ReconcileVendorRosterDTO 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.ReconcileRosterAsync(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 (VendorRosterConflictException conflict) + { + return Conflict(new + { + Status = "Conflict", + Message = _logger.Sanitize(conflict, "Vendor roster changes conflict with open work orders"), + BlockedWorkOrders = conflict.BlockedWorkOrders + }); + } + 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) }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 06251bc..ea76250 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -35,9 +35,10 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] string[]? companies = null, [FromQuery] string[]? trades = null, [FromQuery] string[]? locations = null, - [FromQuery] string[]? jobBuckets = null) + [FromQuery] string[]? jobBuckets = null, + CancellationToken cancellationToken = default) { - var pagedResult = await _vendorService.GetVendorsPagedAsync( + var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync( page, pageSize, search, @@ -45,12 +46,13 @@ namespace Api.SeaHavenIndustries.Controllers companies, trades, locations, - jobBuckets); + jobBuckets, + cancellationToken); var data = pagedResult.Items.Select(v => new { v.Id, - CompanyName = v.Name, + CompanyName = v.CompanyName, v.CompanyId, v.ContactName, v.Email, diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 1310672..c8b2369 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -140,6 +140,10 @@ namespace Data.SeaHavenIndustries .HasIndex(c => c.NormalizedName) .IsUnique(); + builder.Entity() + .Property(c => c.RowVersion) + .IsRowVersion(); + builder.Entity() .HasOne(v => v.Company) .WithMany(c => c.Vendors) @@ -242,6 +246,12 @@ namespace Data.SeaHavenIndustries if (entry.State == EntityState.Modified) entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); } + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } } private static byte[] IncrementRowVersion(byte[]? current) diff --git a/Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.Designer.cs new file mode 100644 index 0000000..4662f4d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.Designer.cs @@ -0,0 +1,3787 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260731230547_SH198_VendorCompanyNotesAndRowVersion")] + partial class SH198_VendorCompanyNotesAndRowVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.cs b/Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.cs new file mode 100644 index 0000000..93bbb64 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH198_VendorCompanyNotesAndRowVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Notes", + table: "VendorCompanies", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "VendorCompanies", + type: "rowversion", + rowVersion: true, + nullable: true); + + // Backfill VendorCompany.Notes (vendor-directory source) deterministically + // from the most recently modified then highest-id nonempty Vendor.Notes. + // Vendor.Notes is retained separately for work-order editor compatibility; + // roster/directory paths read VendorCompany.Notes. + migrationBuilder.Sql(@" +;WITH SourceNotes AS ( + SELECT + v.[CompanyId], + v.[Notes], + ROW_NUMBER() OVER ( + PARTITION BY v.[CompanyId] + ORDER BY + v.[LastModificationTime] DESC, + v.[Id] DESC + ) AS [RowNumber] + FROM [Vendors] v + WHERE v.[CompanyId] IS NOT NULL + AND v.[Notes] IS NOT NULL + AND LTRIM(RTRIM(v.[Notes])) <> '' +) +UPDATE vc +SET vc.[Notes] = src.[Notes] +FROM [VendorCompanies] vc +INNER JOIN SourceNotes src + ON src.[CompanyId] = vc.[Id] +WHERE src.[RowNumber] = 1; +"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Notes", + table: "VendorCompanies"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "VendorCompanies"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 2cb096c..3ab48ca 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2194,6 +2194,14 @@ namespace Data.SeaHavenIndustries.Migrations .IsRequired() .HasColumnType("nvarchar(450)"); + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("State") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/VendorCompany.cs b/Data.SeaHavenIndustries/Models/VendorCompany.cs index 5b2e062..d83d06b 100644 --- a/Data.SeaHavenIndustries/Models/VendorCompany.cs +++ b/Data.SeaHavenIndustries/Models/VendorCompany.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Data.SeaHavenIndustries @@ -13,6 +14,10 @@ namespace Data.SeaHavenIndustries public string? State { get; set; } public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + + [Timestamp] + public byte[]? RowVersion { get; set; } public ICollection? Vendors { get; set; } } diff --git a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs new file mode 100644 index 0000000..be37058 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs @@ -0,0 +1,320 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.DataServices.Implementation +{ + public class VendorCompanyRosterDataService : IVendorCompanyRosterDataService + { + private static readonly string[] TerminalLegacyStatuses = { "completed", "cancelled", "canceled" }; + + private readonly ApplicationDbContext _context; + + public VendorCompanyRosterDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetRosterAsync( + int? vendorId, + int? companyId, + CancellationToken cancellationToken) + { + int? resolvedCompanyId = companyId; + + if (vendorId.HasValue) + { + var vendorCompanyId = await _context.Vendors + .AsNoTracking() + .Where(v => v.Id == vendorId.Value && (v.IsDeleted == null || v.IsDeleted == false)) + .Select(v => v.CompanyId) + .FirstOrDefaultAsync(cancellationToken); + + if (!vendorCompanyId.HasValue || + (companyId.HasValue && vendorCompanyId.Value != companyId.Value)) + return null; + + resolvedCompanyId = vendorCompanyId; + } + + if (!resolvedCompanyId.HasValue) + return null; + + var company = await _context.VendorCompanies + .AsNoTracking() + .Where(c => c.Id == resolvedCompanyId.Value) + .Select(c => new VendorCompanyRosterReadModel + { + CompanyId = c.Id, + Name = c.Name, + CompanyPhone = c.CompanyPhone, + Email = c.Email, + Address = c.Address, + City = c.City, + State = c.State, + Zip = c.Zip, + GoogleMapsUrl = c.GoogleMapsUrl, + Notes = c.Notes, + RowVersion = c.RowVersion + }) + .FirstOrDefaultAsync(cancellationToken); + + if (company == null) + return null; + + var technicians = await _context.Vendors + .AsNoTracking() + .Where(v => v.CompanyId == company.CompanyId + && (v.IsDeleted == null || v.IsDeleted == false)) + .OrderBy(v => v.Id) + .Select(v => new VendorRosterTechnicianReadModel + { + Id = v.Id, + ContactName = v.ContactName, + Phone = v.Phone, + Email = v.Email, + PreferredContact = v.PreferredContact, + TradeSpecialties = v.TradeSpecialties, + IsActive = v.IsActive, + TotalJobs = v.TotalJobs + }) + .ToListAsync(cancellationToken); + + company.Technicians = technicians; + return company; + } + + public async Task SaveRosterAsync( + VendorCompanyRosterWriteModel 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; + var trimmedName = roster.Name.Trim(); + + company.Name = trimmedName; + company.NormalizedName = trimmedName.ToLowerInvariant(); + company.CompanyPhone = roster.CompanyPhone; + company.Email = roster.Email; + company.Address = roster.Address; + company.City = roster.City; + company.State = roster.State; + company.Zip = roster.Zip; + company.GoogleMapsUrl = roster.GoogleMapsUrl; + company.Notes = roster.Notes; + company.LastModificationTime = now; + int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null; + if (actorId.HasValue) + company.LastModifierUserId = actorId; + + var existingTechnicians = await _context.Vendors + .Where(v => v.CompanyId == company.Id && (v.IsDeleted == null || v.IsDeleted == false)) + .ToListAsync(cancellationToken); + + var snapshotIds = roster.Technicians + .Where(t => t.Id.HasValue) + .Select(t => t.Id!.Value) + .ToHashSet(); + + var toRemove = existingTechnicians + .Where(e => !snapshotIds.Contains(e.Id)) + .ToList(); + + // A technician omitted from the snapshot may only be soft-deleted when it has no + // open linked work orders; otherwise the whole reconcile fails (nothing saved). + if (toRemove.Count > 0) + { + var blocked = await GetOpenLinkedWorkOrdersAsync( + toRemove.Select(vendor => vendor.Id).ToArray(), + cancellationToken); + + if (blocked.Count > 0) + throw new VendorRosterConflictException( + "One or more technicians cannot be removed because they have open linked work orders.", + blocked); + } + + foreach (var removed in toRemove) + { + removed.IsActive = false; + removed.IsDeleted = true; + removed.DeletionTime = now; + removed.LastModificationTime = now; + if (actorId.HasValue) + removed.LastModifierUserId = actorId; + } + + foreach (var technician in roster.Technicians) + { + if (technician.Id.HasValue) + { + var existing = existingTechnicians.FirstOrDefault(e => e.Id == technician.Id.Value); + if (existing == null) + continue; + + existing.ContactName = technician.ContactName; + existing.Phone = technician.Phone; + existing.Email = technician.Email; + existing.PreferredContact = technician.PreferredContact; + existing.TradeSpecialties = technician.TradeSpecialties; + existing.IsActive = technician.IsActive; + existing.CompanyName = trimmedName; + 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; + } + else + { + var vendor = new Vendor + { + CompanyName = trimmedName, + 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); + } + } + + await _context.SaveChangesAsync(cancellationToken); + + return await GetRosterAsync(null, company.Id, cancellationToken) + ?? throw new InvalidOperationException("Roster could not be reloaded after save."); + } + + public async Task CreateRosterAsync( + VendorCompanyRosterWriteModel roster, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var trimmedName = roster.Name.Trim(); + + var company = new VendorCompany + { + Name = trimmedName, + NormalizedName = trimmedName.ToLowerInvariant(), + CompanyPhone = roster.CompanyPhone, + Email = roster.Email, + Address = roster.Address, + City = roster.City, + State = roster.State, + Zip = roster.Zip, + GoogleMapsUrl = roster.GoogleMapsUrl, + Notes = roster.Notes, + CreatedDate = now, + createdby = roster.ActorUserId + }; + + await _context.VendorCompanies.AddAsync(company, cancellationToken); + + foreach (var technician in roster.Technicians) + { + var vendor = new Vendor + { + CompanyName = trimmedName, + ContactName = technician.ContactName, + Phone = technician.Phone, + Email = technician.Email, + PreferredContact = technician.PreferredContact, + TradeSpecialties = technician.TradeSpecialties, + IsActive = technician.IsActive, + Company = company, + 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); + } + + await _context.SaveChangesAsync(cancellationToken); + + return await GetRosterAsync(null, company.Id, cancellationToken) + ?? throw new InvalidOperationException("Roster could not be reloaded after create."); + } + + private async Task> GetOpenLinkedWorkOrdersAsync( + IReadOnlyCollection vendorIds, + CancellationToken cancellationToken) + { + var directLinks = _context.Dispatches + .Where(d => vendorIds.Contains(d.VendorId) && d.WorkOrderId.HasValue) + .Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value }); + + var multiLinks = _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && vendorIds.Contains(link.Dispatch.VendorId)) + .Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId }); + + var linkedIds = await directLinks + .Concat(multiLinks) + .Join(_context.workOrders, + link => link.WorkOrderId, + wo => wo.Id, + (link, wo) => new LinkedWorkOrderInfo + { + WorkOrderId = wo.Id, + WorkOrderNumber = wo.WorkerOrderNumber, + WorkOrderTitle = wo.WorkerOrderTitle, + Status = wo.Status, + LifecycleStatus = wo.LifecycleStatus, + ScheduledDate = wo.ScheduledDate, + DispatchId = link.DispatchId + }) + .OrderBy(wo => wo.WorkOrderId) + .ToListAsync(cancellationToken); + + return linkedIds + .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) + .DistinctBy(x => x.WorkOrderId) + .ToList(); + } + + private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus) + { + if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled) + return true; + + if (!string.IsNullOrWhiteSpace(legacyStatus) && + TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant())) + return true; + + return false; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index d6767dc..670ce1c 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using System.Linq.Expressions; using System.Reflection; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -79,6 +80,39 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } + internal IQueryable BuildCompaniesForFacetsQuery(bool? isActive) + { + var query = _context.VendorCompanies + .AsNoTracking(); + + if (isActive == true) + { + // Active: companies with zero non-deleted technicians (company-only rows) + // or at least one active non-deleted technician. + query = query.Where(c => + !_context.Vendors.Any(v => + v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false)) + || _context.Vendors.Any(v => + v.CompanyId == c.Id + && (v.IsDeleted == null || v.IsDeleted == false) + && v.IsActive)); + } + else if (isActive == false) + { + // Inactive: companies with at least one inactive non-deleted technician. + query = query.Where(c => + _context.Vendors.Any(v => + v.CompanyId == c.Id + && (v.IsDeleted == null || v.IsDeleted == false) + && !v.IsActive)); + } + + return query.OrderBy(c => c.Id); + } + + public async Task> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken) + => await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken); + public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -107,6 +141,196 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + public async Task<(IReadOnlyList Items, int TotalCount)> GetDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null, + CancellationToken cancellationToken = default) + { + var filtered = BuildDirectoryUnionQuery( + search, + isActive, + companies, + trades, + locations, + jobBuckets); + + var totalCount = await filtered.CountAsync(cancellationToken); + var items = await filtered + .OrderByDescending(row => row.VendorId.HasValue) + .ThenByDescending(row => row.VendorId ?? 0) + .ThenByDescending(row => row.CompanyId) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + } + + internal IQueryable BuildDirectoryUnionQuery( + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) + { + // Arm A: existing per-technician rows (every non-deleted vendor, including + // those with no company). Notes come from the linked VendorCompany. + var perTechnician = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value); + + var armA = perTechnician.Select(v => new VendorDirectoryRow + { + VendorId = v.Id, + CompanyId = v.CompanyId ?? 0, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + Email = v.Email, + Phone = v.Phone, + CompanyPhone = v.CompanyPhone, + PreferredContact = v.PreferredContact, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + TradeSpecialties = v.TradeSpecialties, + GoogleMapsUrl = v.GoogleMapsUrl, + Notes = v.Company != null ? v.Company.Notes : null, + IsActive = v.IsActive, + TotalJobs = _context.Dispatches + .Where(dispatch => + dispatch.VendorId == v.Id && + dispatch.WorkOrderId.HasValue) + .Select(dispatch => dispatch.WorkOrderId!.Value) + .Union( + _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id) + .Select(link => link.WorkOrderId)) + .Count() + }); + + // Arm B: company-only rows for companies with zero non-deleted technicians. + // These carry a null VendorId and a populated CompanyId. A company has no + // active/inactive flag of its own, so it is shown unless the caller explicitly + // asks for inactive vendors only. + var includeCompanyRows = !isActive.HasValue || isActive.Value; + var union = armA; + + if (includeCompanyRows) + { + var armB = _context.VendorCompanies + .AsNoTracking() + .Where(c => !_context.Vendors + .Any(v => v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false))) + .Select(c => new VendorDirectoryRow + { + VendorId = (int?)null, + CompanyId = c.Id, + CompanyName = c.Name, + ContactName = null, + Email = c.Email, + Phone = null, + CompanyPhone = c.CompanyPhone, + PreferredContact = null, + Address = c.Address, + City = c.City, + State = c.State, + Zip = c.Zip, + TradeSpecialties = null, + GoogleMapsUrl = c.GoogleMapsUrl, + Notes = c.Notes, + IsActive = true, + TotalJobs = 0 + }); + + union = armA.Concat(armB); + } + + return ApplyDirectoryRowFilters( + union, + search, + companies, + trades, + locations, + jobBuckets); + } + + private static IQueryable ApplyDirectoryRowFilters( + IQueryable query, + string? search, + IReadOnlyCollection? companies, + IReadOnlyCollection? trades, + IReadOnlyCollection? locations, + IReadOnlyCollection? jobBuckets) + { + if (!string.IsNullOrWhiteSpace(search)) + { + var normalizedSearch = search.ToLower(); + query = query.Where(row => + (row.CompanyName ?? "").ToLower().Contains(normalizedSearch) || + (row.ContactName ?? "").ToLower().Contains(normalizedSearch) || + (row.Email ?? "").ToLower().Contains(normalizedSearch) || + (row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) || + (row.City ?? "").ToLower().Contains(normalizedSearch) || + (row.State ?? "").ToLower().Contains(normalizedSearch) || + (row.Zip ?? "").ToLower().Contains(normalizedSearch)); + } + + var selectedCompanies = companies? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedCompanies.Length > 0) + query = query.Where(row => + selectedCompanies.Contains((row.CompanyName ?? "").ToLower())); + + var selectedTrades = trades? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedTrades.Length > 0) + query = query.Where(BuildTradePredicate(selectedTrades)); + + var selectedLocations = locations? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedLocations.Length > 0) + query = query.Where(BuildLocationPredicate(selectedLocations)); + + var selectedJobBuckets = jobBuckets? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedJobBuckets.Length == 0) + return query; + + var under50 = selectedJobBuckets.Contains("under-50"); + var from50To99 = selectedJobBuckets.Contains("50-99"); + var from100To149 = selectedJobBuckets.Contains("100-149"); + var from150 = selectedJobBuckets.Contains("150-plus"); + + return query.Where(row => + (under50 && row.TotalJobs < 50) || + (from50To99 && row.TotalJobs >= 50 && row.TotalJobs <= 99) || + (from100To149 && row.TotalJobs >= 100 && row.TotalJobs <= 149) || + (from150 && row.TotalJobs >= 150)); + } + internal IQueryable BuildDirectoryQuery( string? search = null, bool? isActive = true, @@ -150,7 +374,7 @@ namespace SeaHaven.DataServices.Implementation .Distinct() .ToArray() ?? Array.Empty(); if (selectedTrades.Length > 0) - query = query.Where(BuildTradePredicate(selectedTrades)); + query = query.Where(BuildTradePredicate(selectedTrades)); var selectedLocations = locations? .Where(value => !string.IsNullOrWhiteSpace(value)) @@ -158,7 +382,7 @@ namespace SeaHaven.DataServices.Implementation .Distinct() .ToArray() ?? Array.Empty(); if (selectedLocations.Length > 0) - query = query.Where(BuildLocationPredicate(selectedLocations)); + query = query.Where(BuildLocationPredicate(selectedLocations)); var result = ProjectWithTotalJobs(query); var selectedJobBuckets = jobBuckets? @@ -216,10 +440,10 @@ namespace SeaHaven.DataServices.Implementation private static readonly MethodInfo StringConcatMethod = typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!; - private static Expression> BuildTradePredicate( + private static Expression> BuildTradePredicate( IReadOnlyCollection selectedTrades) { - var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var vendor = Expression.Parameter(typeof(T), "vendor"); var specialties = Expression.Coalesce( Expression.Property(vendor, nameof(Vendor.TradeSpecialties)), Expression.Constant(string.Empty)); @@ -247,13 +471,13 @@ namespace SeaHaven.DataServices.Implementation } } - return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); } - private static Expression> BuildLocationPredicate( + private static Expression> BuildLocationPredicate( IReadOnlyCollection selectedLocations) { - var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var vendor = Expression.Parameter(typeof(T), "vendor"); var label = BuildLocationLabelBody(vendor); var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes); @@ -261,7 +485,7 @@ namespace SeaHaven.DataServices.Implementation foreach (var location in selectedLocations) body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location))); - return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); } private static Expression BuildLocationLabelBody(ParameterExpression vendor) diff --git a/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs new file mode 100644 index 0000000..c148793 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs @@ -0,0 +1,28 @@ +using SeaHaven.DataServices.Models; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IVendorCompanyRosterDataService + { + // Loads the company roster from either a vendor row id and/or a company id, + // returning the company plus all non-deleted technician rows. + Task GetRosterAsync( + int? vendorId, + int? companyId, + CancellationToken cancellationToken); + + // Reconciles the company roster (add/update/soft-delete/rename) and updates the + // company Notes in a single SaveChangesAsync. A technician omitted from the + // snapshot is soft-deleted only when it has no open linked work orders; otherwise + // the whole save fails (VendorRosterConflictException) and nothing is persisted. + Task SaveRosterAsync( + VendorCompanyRosterWriteModel roster, + CancellationToken cancellationToken); + + // Creates a new company together with its initial technicians (which may be zero) + // in a single SaveChangesAsync. + Task CreateRosterAsync( + VendorCompanyRosterWriteModel roster, + CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 3324b6e..c632c75 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -13,6 +14,13 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task> GetActiveVendorsAsync(CancellationToken cancellationToken); Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); + + // SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies, + // with status semantics aligned to BuildDirectoryUnionQuery. Null returns all + // companies; active includes companies with zero non-deleted technicians or at + // least one active non-deleted technician; inactive includes companies with at + // least one inactive non-deleted technician. + Task> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken); Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -22,6 +30,20 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null); + + // SH-198 vendor directory: per-technician rows UNION company-only rows + // (companies with zero non-deleted technicians). Company-only rows carry a + // null VendorId and a populated CompanyId. Notes come from VendorCompany.Notes. + Task<(IReadOnlyList Items, int TotalCount)> GetDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null, + CancellationToken cancellationToken = default); Task AddAsync(Vendor vendor); Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); diff --git a/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs new file mode 100644 index 0000000..c3899d5 --- /dev/null +++ b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs @@ -0,0 +1,74 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Models +{ + public sealed class VendorCompanyRosterReadModel + { + public int CompanyId { get; set; } + public string Name { get; set; } = string.Empty; + 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; } + public byte[]? RowVersion { get; set; } + public List Technicians { get; set; } = new(); + } + + public sealed class VendorRosterTechnicianReadModel + { + public int Id { get; set; } + public string? ContactName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? PreferredContact { get; set; } + public string? TradeSpecialties { get; set; } + public bool IsActive { get; set; } + public int TotalJobs { get; set; } + } + + public sealed class VendorCompanyRosterWriteModel + { + public int CompanyId { get; set; } + public byte[]? RowVersion { get; set; } + public string Name { get; set; } = string.Empty; + 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; } + public string? ActorUserId { get; set; } + public List Technicians { get; set; } = new(); + } + + public sealed class RosterTechnicianWriteModel + { + public int? Id { get; set; } + public string? ContactName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? PreferredContact { get; set; } + public string? TradeSpecialties { get; set; } + public bool IsActive { get; set; } = true; + } + + // 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. + public sealed class VendorRosterConflictException : Exception + { + public IReadOnlyList BlockedWorkOrders { get; } + + public VendorRosterConflictException(string message, IReadOnlyList blockedWorkOrders) + : base(message) + { + BlockedWorkOrders = blockedWorkOrders; + } + } +} diff --git a/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs new file mode 100644 index 0000000..763f020 --- /dev/null +++ b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs @@ -0,0 +1,23 @@ +namespace SeaHaven.DataServices.Models +{ + public sealed class VendorDirectoryRow + { + public int? VendorId { get; set; } + public int CompanyId { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } + public int TotalJobs { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs new file mode 100644 index 0000000..0d97b68 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs @@ -0,0 +1,70 @@ +namespace SeaHaven.Services.DTOs +{ + public class VendorRosterDTO + { + public int CompanyId { get; set; } + public string Name { get; set; } = string.Empty; + 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; } + public string? RowVersion { get; set; } + public List Technicians { get; set; } = new(); + } + + public class VendorRosterTechnicianDTO + { + public int? Id { get; set; } + public string? ContactName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? PreferredContact { get; set; } + public string? TradeSpecialties { get; set; } + public bool IsActive { get; set; } + public int TotalJobs { get; set; } + } + + public class RosterTechnicianInputDTO + { + public int? Id { get; set; } + public string? ContactName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? PreferredContact { get; set; } + public string? TradeSpecialties { get; set; } + public bool? IsActive { get; set; } + } + + public class CreateVendorRosterDTO + { + public required 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; } + public List Technicians { get; set; } = new(); + } + + public class ReconcileVendorRosterDTO + { + public required string RowVersion { get; set; } + public required 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; } + public List Technicians { get; set; } = new(); + } +} diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 780b724..908c5c5 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -21,6 +21,29 @@ namespace SeaHaven.Services.DTOs public bool IsActive { get; set; } } + // SH-198 vendor directory row. Id is the nullable vendor id (null for company-only + // rows that have zero technicians); CompanyId is always populated. + public class VendorDirectoryItemDTO + { + public int? Id { get; set; } + public int? CompanyId { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public int TotalJobs { get; set; } + public bool IsActive { get; set; } + } + public class CreateVendorDTO { public required string Name { get; set; } diff --git a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs new file mode 100644 index 0000000..30ca3b6 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs @@ -0,0 +1,288 @@ +using FluentValidation; +using FluentValidation.Results; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHaven.Services.Implementation +{ + public class VendorCompanyRosterService : IVendorCompanyRosterService + { + private readonly IVendorCompanyRosterDataService _rosterDataService; + + public VendorCompanyRosterService(IVendorCompanyRosterDataService rosterDataService) + { + _rosterDataService = rosterDataService; + } + + public async Task GetRosterAsync( + int? vendorId, + int? companyId, + string userId, + CancellationToken cancellationToken) + { + EnsureAuthenticated(userId); + + if (!vendorId.HasValue && !companyId.HasValue) + throw new ValidationException("A vendor id or company id is required."); + + var roster = await _rosterDataService.GetRosterAsync(vendorId, companyId, cancellationToken); + return roster == null ? null : MapToDTO(roster); + } + + public async Task CreateRosterAsync( + CreateVendorRosterDTO dto, + string userId, + CancellationToken cancellationToken) + { + EnsureAuthenticated(userId); + dto.Technicians ??= new List(); + NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians); + + if (dto.Technicians.Any(technician => technician.Id.HasValue)) + throw new ValidationException(new[] + { + new ValidationFailure( + nameof(CreateVendorRosterDTO.Technicians), + "Technician ids are not allowed when creating a vendor company.") + }); + + var writeModel = new VendorCompanyRosterWriteModel + { + Name = dto.Name, + CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone), + Email = dto.Email, + Address = dto.Address, + City = dto.City, + State = dto.State, + Zip = dto.Zip, + GoogleMapsUrl = dto.GoogleMapsUrl, + Notes = dto.Notes, + ActorUserId = userId, + Technicians = MapTechnicians(dto.Technicians) + }; + + var saved = await _rosterDataService.CreateRosterAsync(writeModel, cancellationToken); + return MapToDTO(saved); + } + + public async Task ReconcileRosterAsync( + int companyId, + ReconcileVendorRosterDTO dto, + string userId, + CancellationToken cancellationToken) + { + EnsureAuthenticated(userId); + dto.Technicians ??= new List(); + NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians); + + byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion); + + // Mismatched ids: every non-null technician id must belong to this company. + await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken); + + var writeModel = new VendorCompanyRosterWriteModel + { + CompanyId = companyId, + RowVersion = rowVersion, + Name = dto.Name, + CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone), + Email = dto.Email, + Address = dto.Address, + City = dto.City, + State = dto.State, + Zip = dto.Zip, + GoogleMapsUrl = dto.GoogleMapsUrl, + Notes = dto.Notes, + ActorUserId = userId, + Technicians = MapTechnicians(dto.Technicians) + }; + + var saved = await _rosterDataService.SaveRosterAsync(writeModel, cancellationToken); + return MapToDTO(saved); + } + + private static void EnsureAuthenticated(string userId) + { + if (string.IsNullOrWhiteSpace(userId)) + throw new UnauthorizedAccessException("An authenticated user is required."); + } + + private static void NormalizeAndValidateCompanyFields( + string name, + string? companyPhone, + string? email, + string? googleMapsUrl, + List technicians) + { + var failures = new List(); + + if (string.IsNullOrWhiteSpace(name)) + failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Name), "Company name is required.")); + + // Contact group: the company must expose at least one contact channel. + var normalizedCompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(companyPhone); + if (string.IsNullOrWhiteSpace(normalizedCompanyPhone) && string.IsNullOrWhiteSpace(email)) + failures.Add(new ValidationFailure( + nameof(CreateVendorRosterDTO.CompanyPhone), + "At least one company phone or email is required.")); + + if (!string.IsNullOrWhiteSpace(normalizedCompanyPhone) && + !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedCompanyPhone)) + failures.Add(new ValidationFailure( + nameof(CreateVendorRosterDTO.CompanyPhone), + "Company phone must be in North American format: (XXX) XXX-XXXX")); + + if (!string.IsNullOrWhiteSpace(email) && !BeValidEmail(email)) + failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Email), "Invalid email address.")); + + if (!string.IsNullOrWhiteSpace(googleMapsUrl) && !BeValidAbsoluteHttpsUrl(googleMapsUrl)) + failures.Add(new ValidationFailure( + nameof(CreateVendorRosterDTO.GoogleMapsUrl), + "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")); + } + + // Duplicate technician ids are not allowed. + var duplicateIds = technicians + .Where(t => t.Id.HasValue) + .GroupBy(t => t.Id!.Value) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToList(); + + if (duplicateIds.Count > 0) + failures.Add(new ValidationFailure( + nameof(CreateVendorRosterDTO.Technicians), + "Duplicate technician ids are not allowed.")); + + if (failures.Count > 0) + throw new ValidationException(failures); + } + + private async Task EnsureTechnicianIdsBelongToCompanyAsync( + int companyId, + List technicians, + CancellationToken cancellationToken) + { + var requestedIds = technicians + .Where(t => t.Id.HasValue) + .Select(t => t.Id!.Value) + .Distinct() + .ToList(); + + if (requestedIds.Count == 0) + return; + + var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken); + if (roster == null) + throw new ValidationException(new[] + { + new ValidationFailure(nameof(ReconcileVendorRosterDTO.Name), $"No vendor company exists with ID {companyId}.") + }); + + var validIds = roster.Technicians.Select(t => t.Id).ToHashSet(); + var mismatched = requestedIds.Where(id => !validIds.Contains(id)).ToList(); + + if (mismatched.Count > 0) + throw new ValidationException(new[] + { + new ValidationFailure( + nameof(ReconcileVendorRosterDTO.Technicians), + $"Technician ids do not belong to company {companyId}: {string.Join(", ", mismatched)}.") + }); + } + + private static List MapTechnicians(List technicians) + { + return technicians.Select(t => new RosterTechnicianWriteModel + { + Id = t.Id, + ContactName = t.ContactName, + Phone = t.Phone, + Email = t.Email, + PreferredContact = t.PreferredContact, + TradeSpecialties = t.TradeSpecialties, + IsActive = t.IsActive ?? true + }).ToList(); + } + + private static byte[] ParseRequiredRowVersion(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + throw new ValidationException(new[] + { + new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Row version is required.") + }); + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new ValidationException(new[] + { + new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Invalid row version format.") + }); + } + } + + private static bool BeValidEmail(string? value) => + System.Net.Mail.MailAddress.TryCreate(value, out var address) && address.Address == value; + + private static bool BeValidAbsoluteHttpsUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; + + private static VendorRosterDTO MapToDTO(VendorCompanyRosterReadModel roster) => new() + { + CompanyId = roster.CompanyId, + Name = roster.Name, + CompanyPhone = roster.CompanyPhone, + Email = roster.Email, + Address = roster.Address, + City = roster.City, + State = roster.State, + Zip = roster.Zip, + GoogleMapsUrl = roster.GoogleMapsUrl, + Notes = roster.Notes, + RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion), + Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO + { + Id = t.Id, + ContactName = t.ContactName, + Phone = t.Phone, + Email = t.Email, + PreferredContact = t.PreferredContact, + TradeSpecialties = t.TradeSpecialties, + IsActive = t.IsActive, + TotalJobs = t.TotalJobs + }).ToList() + }; + } +} diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 117b0da..fe82c09 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -4,6 +4,7 @@ using FluentValidation; using FluentValidation.Results; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; @@ -89,6 +90,58 @@ namespace SeaHaven.Services.Implementation }; } + public async Task> GetVendorDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null, + CancellationToken cancellationToken = default) + { + var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets, + cancellationToken); + + return new PagedResult + { + Items = items.Select(MapToDirectoryItem).ToList(), + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new() + { + Id = row.VendorId, + CompanyId = row.CompanyId == 0 ? null : row.CompanyId, + CompanyName = row.CompanyName, + ContactName = row.ContactName, + Email = row.Email, + Phone = row.Phone, + CompanyPhone = row.CompanyPhone, + PreferredContact = row.PreferredContact, + Address = row.Address, + City = row.City, + State = row.State, + Zipcode = row.Zip, + TradeSpecialties = row.TradeSpecialties, + GoogleMapsUrl = row.GoogleMapsUrl, + Notes = row.Notes, + IsActive = row.IsActive, + TotalJobs = row.TotalJobs + }; + public async Task CreateVendorAsync(CreateVendorDTO dto, string userId) { NormalizePhoneFields(dto); @@ -569,25 +622,29 @@ namespace SeaHaven.Services.Implementation public async Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) { + // Companies and Locations are sourced from company-owned VendorCompany fields + // (status semantics aligned to the directory union); Trades remain derived from + // non-deleted technician Vendors. + var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken); var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken); - var companies = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) - .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) + var companyFacets = companies + .Where(c => !string.IsNullOrWhiteSpace(c.Name)) + .GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) - .OrderBy(v => v.CompanyName) - .Select(v => new VendorFacetCompanyDTO + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(c => new VendorFacetCompanyDTO { - Name = v.CompanyName, - CompanyId = v.CompanyId, - CompanyPhone = v.CompanyPhone, - Email = v.Email, - PreferredContact = v.PreferredContact, - Address = v.Address, - City = v.City, - State = v.State, - Zip = v.Zip, - GoogleMapsUrl = v.GoogleMapsUrl + Name = c.Name, + CompanyId = c.Id, + CompanyPhone = c.CompanyPhone, + Email = c.Email, + PreferredContact = null, + Address = c.Address, + City = c.City, + State = c.State, + Zip = c.Zip, + GoogleMapsUrl = c.GoogleMapsUrl }); var trades = vendors @@ -596,20 +653,20 @@ namespace SeaHaven.Services.Implementation .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value); - var locations = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) - .Select(v => new VendorFacetLocationDTO + var locations = companies + .Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State)) + .Select(c => new VendorFacetLocationDTO { - City = v.City, - State = v.State, - Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) + City = c.City, + State = c.State, + Label = string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label); return new VendorFacetsDTO { - Companies = companies, + Companies = companyFacets, Trades = trades, Locations = locations, JobBuckets = new[] diff --git a/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs new file mode 100644 index 0000000..125b151 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs @@ -0,0 +1,24 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorCompanyRosterService + { + Task GetRosterAsync( + int? vendorId, + int? companyId, + string userId, + CancellationToken cancellationToken); + + Task CreateRosterAsync( + CreateVendorRosterDTO dto, + string userId, + CancellationToken cancellationToken); + + Task ReconcileRosterAsync( + int companyId, + ReconcileVendorRosterDTO dto, + string userId, + CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 18dd029..b7238da 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -16,6 +16,18 @@ namespace SeaHaven.Services.Interfaces IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null); + + // SH-198 vendor directory: per-technician rows UNION company-only rows. + Task> GetVendorDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null, + CancellationToken cancellationToken = default); Task CreateVendorAsync(CreateVendorDTO dto, string userId); Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId); Task DeleteVendorAsync(int id, string userId); diff --git a/SeaHaven.Services/Validation/VendorValidation.cs b/SeaHaven.Services/Validation/VendorValidation.cs index 5ee2c06..693178f 100644 --- a/SeaHaven.Services/Validation/VendorValidation.cs +++ b/SeaHaven.Services/Validation/VendorValidation.cs @@ -20,9 +20,16 @@ namespace SeaHaven.Services.Validation .EmailAddress().WithMessage("Invalid email address") .When(x => !string.IsNullOrEmpty(x.Email)); + // SH-199: technician (ContactName/Phone) is optional. The company must still + // expose at least one contact channel: a company phone or an email. + RuleFor(x => x) + .Must(dto => !string.IsNullOrWhiteSpace(dto.CompanyPhone) || !string.IsNullOrWhiteSpace(dto.Email)) + .WithMessage("At least one company phone or email is required") + .OverridePropertyName(nameof(CreateVendorDTO.CompanyPhone)); + RuleFor(x => x.ContactName) - .NotEmpty().WithMessage("Technician name is required") - .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters"); + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") + .When(x => !string.IsNullOrEmpty(x.ContactName)); RuleFor(x => x.PreferredContact) .Must(VendorValidationRules.BeValidPreferredContact)