diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs index b10a37d..e57a307 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs @@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.CreateRosterAsync(It.IsAny(), 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())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), 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); @@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_DuplicateName_ReturnsStable409() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterDuplicateNameException( "database detail", new InvalidOperationException("IX_VendorCompanies_NormalizedName"))); @@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), 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())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), 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); @@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), 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); @@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests 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())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), 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); @@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), 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); @@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Patch_AddsTechnicians_Returns200WithRoster() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians.")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None); @@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); } + + [Fact] + public async Task AreaAssignmentForbidden_Returns403OnEveryMutation() + { + var service = new Mock(); + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + var controller = NewController(service); + + var results = new[] + { + await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None), + await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None), + await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None) + }; + + foreach (var result in results) + result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + + service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", controller.User, It.IsAny()), Times.Once); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs index 6114c31..cd7627c 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs @@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests await act.Should().ThrowAsync(); } + + [Fact] + public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName() + { + var dbName = Guid.NewGuid().ToString(); + using var context = NewContext(dbName); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 2, Name = "Central", NormalizedName = "central" }); + await context.SaveChangesAsync(); + var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech"); + var service = new VendorCompanyRosterDataService(context); + + VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new() + { + CompanyId = companyId, + Name = "Area Co", + Email = "area@example.com", + AreaIdProvided = provided, + AreaId = areaId, + Technicians = context.Vendors.Where(v => v.CompanyId == companyId) + .Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName }) + .ToList() + }; + + var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None); + assigned.AreaId.Should().Be(2); + assigned.AreaName.Should().Be("Central"); + + var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None); + untouched.AreaId.Should().Be(2); + + var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None); + cleared.AreaId.Should().BeNull(); + cleared.AreaName.Should().BeNull(); + + (await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue(); + (await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs index 5991fd5..72b29db 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Moq; +using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; @@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorCompanyRosterServiceTests { + private static readonly ClaimsPrincipal Dispatcher = Principal("User"); + private static readonly ClaimsPrincipal Admin = Principal("Admin"); + + private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) }, + "Test")); + private static VendorCompanyRosterService NewService(Mock data) => new(data.Object); private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new() @@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone)); @@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 7, ContactName = "Riley" } } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List() - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.Technicians.Should().BeEmpty(); @@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests GoogleMapsUrl = "http://maps.google.com/acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl)); @@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "Riley", Phone = "+1 312 555 0100" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100"); } @@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme Co", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests Technicians = null! }; - await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().BeEmpty(); } @@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "", Phone = "", PreferredContact = "Phone" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().ContainSingle(); captured.Technicians.Single().ContactName.Should().BeEmpty(); @@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 1, ContactName = "T" } } }; - var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); } @@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", cts.Token); + }, "42", Dispatcher, cts.Token); data.Verify(x => x.SaveRosterAsync(It.IsAny(), cts.Token), Times.Once); } @@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests public async Task AddTechnicians_WithoutAuthenticatedUser_Throws() { var data = new Mock(); - var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians)); @@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests AddTechnicians = new List { new() { ContactName = "T" } } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion)); @@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests { RowVersion = "AAAAAAAAD8I=", CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields!.Notes.Should().Be(notes); } @@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); // SH-250: every field is blank, so there is no company change to apply. Forwarding a // non-null write model made the data layer bump RowVersion and rewrite every @@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify( @@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields.Should().NotBeNull(); captured.CompanyFields!.City.Should().Be("Norfolk"); @@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests .ReturnsAsync(SampleReadModel(7, 1)); using var cts = new CancellationTokenSource(); - await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token); + await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), cts.Token), Times.Once); } + + private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId) + { + var model = SampleReadModel(7, 1); + model.AreaId = areaId; + return model; + } + + [Fact] + public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation() + { + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny())).ReturnsAsync(false); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId)); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningActiveArea_PersistsAreaId() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(2)); + + var result = await NewService(data).CreateRosterAsync( + new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 }, + "42", + Admin, + cts.Token); + + captured!.AreaId.Should().Be(2); + result.AreaId.Should().Be(2); + data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once); + } + + [Fact] + public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + + foreach (int? requested in new int?[] { 3, null }) + { + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = requested + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + var captured = new List(); + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured.Add(model)) + .ReturnsAsync(ReadModelWithArea(1)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com", + AreaId = 1 + }, "42", Dispatcher, CancellationToken.None); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com" + }, "42", Dispatcher, CancellationToken.None); + + captured.Should().HaveCount(2); + captured[0].AreaIdProvided.Should().BeTrue(); + captured[0].AreaId.Should().Be(1); + captured[1].AreaIdProvided.Should().BeFalse(); + } + + [Fact] + public async Task Reconcile_AdminClearsAreaToUnassigned() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(null)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = null + }, "42", Admin, CancellationToken.None); + + captured!.AreaIdProvided.Should().BeTrue(); + captured.AreaId.Should().BeNull(); + data.Verify(x => x.IsActiveAreaAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_NonAdminAreaChange_IsForbidden() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(null)); + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 } + }; + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull() + { + var omitted = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var cleared = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var assigned = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + + omitted.AreaIdProvided.Should().BeFalse(); + cleared.AreaIdProvided.Should().BeTrue(); + cleared.AreaId.Should().BeNull(); + assigned.AreaIdProvided.Should().BeTrue(); + assigned.AreaId.Should().Be(3); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index 99b7d1d..e0d34e0 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -72,6 +72,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), It.IsAny()), Times.Never); } @@ -155,6 +156,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), token)) .ReturnsAsync(new PagedResult { @@ -203,6 +205,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), token), Times.Once); service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( It.IsAny(), diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 7e7716a..4fc5ee1 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -470,12 +470,14 @@ public class VendorDataServiceTests search: "alice", trades: new[] { "Plumbing" }, locations: new[] { "Chicago, IL" }, - jobBuckets: new[] { "under-50" }).ToQueryString(); + jobBuckets: new[] { "under-50" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString(); sql.Should().Contain("GROUP BY"); sql.Should().Contain("LEFT JOIN"); sql.Should().Contain("TradeSpecialties"); sql.Should().Contain("TotalJobs"); + sql.Should().Contain("AreaId"); Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase) .Should().BeFalse("company totals must sum pre-aggregated scalar job counts"); } @@ -1044,4 +1046,69 @@ public class VendorDataServiceTests sql.Should().Contain("SELECT"); sql.Should().Contain("VendorCompanies"); } + + [Fact] + public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows() + { + await using var context = NewContext(); + var east = new Area { Id = 1, Name = "East", NormalizedName = "east" }; + var west = new Area { Id = 3, Name = "West", NormalizedName = "west" }; + context.Areas.AddRange(east, west); + var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east }; + var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west }; + var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" }; + var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east }; + context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly); + context.Vendors.AddRange( + new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false }, + new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false }, + new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var eastResult = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastResult.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only"); + eastResult.TotalCount.Should().Be(2); + var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co"); + eastRow.AreaId.Should().Be(east.Id); + eastRow.AreaName.Should().Be("East"); + eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East"); + + var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true }); + eastOrUnassigned.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co"); + eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull(); + + var eastAndBoston = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "Boston, MA" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co"); + + var matchesNobody = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter()); + matchesNobody.TotalCount.Should().Be(0); + matchesNobody.Items.Should().BeEmpty(); + + var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true); + unfiltered.TotalCount.Should().Be(5); + } + + [Fact] + public async Task GetActiveAreasAsync_ExcludesArchivedAreas() + { + await using var context = NewContext(); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false }); + await context.SaveChangesAsync(); + + var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None); + + areas.Select(area => area.Name).Should().Equal("East"); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index a8e996d..48351df 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -166,6 +166,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), token)) .ReturnsAsync((new[] { @@ -212,6 +213,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), token), Times.Once); } @@ -228,6 +230,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), It.IsAny())) .ReturnsAsync((new List(), 0)); @@ -756,6 +759,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); @@ -794,6 +799,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -822,6 +829,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -849,6 +858,8 @@ public class VendorServiceTests SortOrder = index + 1 }) .ToList()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, cts.Token); @@ -898,4 +909,96 @@ public class VendorServiceTests TradeCatalog.CanonicalTrades, opts => opts.WithStrictOrdering()); } + + private static Mock DirectoryDataWithAreas(Action capture) + { + var data = new Mock(); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 3, Name = "West", NormalizedName = "west" } + }); + data.Setup(x => x.GetCompanyDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, IReadOnlyCollection?, IReadOnlyCollection?, IReadOnlyCollection?, VendorAreaFilter?, CancellationToken>( + (_, _, _, _, _, _, _, _, filter, _) => capture(filter)) + .ReturnsAsync((new List(), 0)); + return data; + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().Equal(1); + captured.IncludeUnassigned.Should().BeTrue(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + // 4 is not in the active catalogue (unknown or archived); "West" is a label. + var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { "4", "West", "-3" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().BeEmpty(); + captured.IncludeUnassigned.Should().BeFalse(); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter() + { + VendorAreaFilter? captured = new(); + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" }); + + captured.Should().BeNull(); + data.Verify(x => x.GetActiveAreasAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(cts.Token)) + .ReturnsAsync(new List + { + new() { Id = 3, Name = "West", NormalizedName = "west" }, + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 4, Name = "california", NormalizedName = "california" } + }); + + var facets = await NewService(data).GetFacetsAsync(null, cts.Token); + + facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal( + (4, "california"), (1, "East"), (3, "West")); + data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once); + } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs index fa5e4fa..1930fe9 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs @@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken); + var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (VendorRosterDuplicateNameException dup) { return Conflict(new @@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); @@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index fc42b88..2db1eba 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] string[]? trades = null, [FromQuery] string[]? locations = null, [FromQuery] string[]? jobBuckets = null, + [FromQuery] string[]? areas = null, CancellationToken cancellationToken = default) { var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync( @@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers trades, locations, jobBuckets, + areas, cancellationToken); var data = pagedResult.Items.Select(v => new @@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers v.Notes, v.IsActive, v.TotalJobs, + v.AreaId, + v.AreaName, Technicians = v.Technicians.Select(t => new { t.Id, diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 8426000..5dab440 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -253,6 +253,25 @@ namespace Data.SeaHavenIndustries .WithMany(s => s.SupportedWorkOrderTypes) .HasForeignKey(t => t.ServiceId) .OnDelete(DeleteBehavior.Restrict); + + // SH-278 Area catalogue: bounded names so the unique key is indexable. + builder.Entity() + .Property(a => a.Name) + .HasMaxLength(128); + + builder.Entity() + .Property(a => a.NormalizedName) + .HasMaxLength(128); + + builder.Entity() + .HasIndex(a => a.NormalizedName) + .IsUnique(); + + builder.Entity() + .HasOne(c => c.Area) + .WithMany() + .HasForeignKey(c => c.AreaId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -275,6 +294,7 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderExternalReceipts { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Trades { get; set; } + public DbSet Areas { get; set; } public DbSet Vendors { get; set; } public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs new file mode 100644 index 0000000..2e442c7 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs @@ -0,0 +1,3977 @@ +// +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("20260916140128_SH278_VendorCompanyArea")] + partial class SH278_VendorCompanyArea + { + /// + 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("AccountId") + .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("AccountId"); + + 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.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("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("SiteContactOrder") + .HasColumnType("int"); + + 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("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + 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("AccountId") + .HasColumnType("int"); + + 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.HasIndex("AccountId"); + + 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.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + 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("AreaId") + .HasColumnType("int"); + + 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("AreaId"); + + 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("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + 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("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + 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("AvetaRequired") + .HasColumnType("bit"); + + 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("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + 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("AccountId"); + + 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.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + 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("Contacts") + .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.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + 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.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + 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.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .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.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + 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("Account"); + + 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("Contacts"); + + 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/20260916140128_SH278_VendorCompanyArea.cs b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.cs new file mode 100644 index 0000000..8c090d4 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH278_VendorCompanyArea : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AreaId", + table: "VendorCompanies", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "Areas", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + NormalizedName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + IsActive = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Areas", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompanies_AreaId", + table: "VendorCompanies", + column: "AreaId"); + + migrationBuilder.CreateIndex( + name: "IX_Areas_NormalizedName", + table: "Areas", + column: "NormalizedName", + unique: true); + + // SH-278 v1 seed of the four dispatcher territories confirmed by Product. + // Names match the merged SH-273 Sites mapping (East / Central / West / + // California). Ids are stable; later renames change only the display name. + // Existing vendor companies stay Unassigned (no backfill). + migrationBuilder.InsertData( + table: "Areas", + columns: new[] { "Id", "Name", "NormalizedName", "IsActive" }, + values: new object[,] + { + { 1, "East", "east", true }, + { 2, "Central", "central", true }, + { 3, "West", "west", true }, + { 4, "California", "california", true } + }); + + migrationBuilder.AddForeignKey( + name: "FK_VendorCompanies_Areas_AreaId", + table: "VendorCompanies", + column: "AreaId", + principalTable: "Areas", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_VendorCompanies_Areas_AreaId", + table: "VendorCompanies"); + + migrationBuilder.DropTable( + name: "Areas"); + + migrationBuilder.DropIndex( + name: "IX_VendorCompanies_AreaId", + table: "VendorCompanies"); + + migrationBuilder.DropColumn( + name: "AreaId", + table: "VendorCompanies"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 04dfa9e..730bc42 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -286,6 +286,35 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => { b.Property("Id") @@ -2352,6 +2381,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Address") .HasColumnType("nvarchar(max)"); + b.Property("AreaId") + .HasColumnType("int"); + b.Property("City") .HasColumnType("nvarchar(max)"); @@ -2409,6 +2441,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); + b.HasIndex("AreaId"); + b.HasIndex("NormalizedName") .IsUnique(); @@ -3750,6 +3784,16 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => { b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") diff --git a/Data.SeaHavenIndustries/Models/Area.cs b/Data.SeaHavenIndustries/Models/Area.cs new file mode 100644 index 0000000..0d124cb --- /dev/null +++ b/Data.SeaHavenIndustries/Models/Area.cs @@ -0,0 +1,15 @@ +namespace Data.SeaHavenIndustries +{ + // SH-278: dispatcher territory catalogue for this Sea Haven deployment. Vendor + // companies carry zero or one Area by stable id; the display name is separate and + // may be renamed without changing assignments. Area is selected independently and + // is never derived from a company's State. Rows are organization-wide: vendor + // companies are not scoped to a customer Account. + public class Area + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string NormalizedName { get; set; } = ""; + public bool IsActive { get; set; } = true; + } +} diff --git a/Data.SeaHavenIndustries/Models/VendorCompany.cs b/Data.SeaHavenIndustries/Models/VendorCompany.cs index d83d06b..844a944 100644 --- a/Data.SeaHavenIndustries/Models/VendorCompany.cs +++ b/Data.SeaHavenIndustries/Models/VendorCompany.cs @@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + // SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned. + public int? AreaId { get; set; } + [ForeignKey(nameof(AreaId))] + public Area? Area { get; set; } + [Timestamp] public byte[]? RowVersion { get; set; } diff --git a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs index aacdf9c..cac9637 100644 --- a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs @@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation _context = context; } + public Task IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) => + _context.Areas + .AsNoTracking() + .AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken); + public async Task GetRosterAsync( int? vendorId, int? companyId, @@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation Zip = c.Zip, GoogleMapsUrl = c.GoogleMapsUrl, Notes = c.Notes, + AreaId = c.AreaId, + AreaName = c.Area != null ? c.Area.Name : null, RowVersion = c.RowVersion }) .FirstOrDefaultAsync(cancellationToken); @@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation company.Zip = roster.Zip; company.GoogleMapsUrl = roster.GoogleMapsUrl; company.Notes = roster.Notes; + if (roster.AreaIdProvided) + company.AreaId = roster.AreaId; company.LastModificationTime = now; int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null; if (actorId.HasValue) @@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation Zip = roster.Zip, GoogleMapsUrl = roster.GoogleMapsUrl, Notes = roster.Notes, + AreaId = roster.AreaId, CreatedDate = now, createdby = roster.ActorUserId }; @@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation company.GoogleMapsUrl = fields.GoogleMapsUrl; if (fields.Notes != null) company.Notes = fields.Notes; + if (fields.AreaIdProvided) + company.AreaId = fields.AreaId; // Keep the denormalized company columns on existing technicians aligned // with the company row (same invariant SaveRosterAsync maintains). This diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 3c5e70a..2268f53 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation return query.OrderBy(c => c.Id); } + public async Task> GetActiveAreasAsync(CancellationToken cancellationToken) + { + return await _context.Areas + .AsNoTracking() + .Where(a => a.IsActive) + .OrderBy(a => a.Id) + .ToListAsync(cancellationToken); + } + public async Task> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken) => await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken); @@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + VendorAreaFilter? areaFilter = null, CancellationToken cancellationToken = default) { // Phase 1: select and paginate bounded company group keys in SQL. @@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation companies, trades, locations, - jobBuckets); + jobBuckets, + areaFilter); var totalCount = await keyQuery.CountAsync(cancellationToken); @@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation ? new Dictionary() : await _context.VendorCompanies .AsNoTracking() + .Include(c => c.Area) .Where(c => missingCompanyIds.Contains(c.Id)) .ToDictionaryAsync(c => c.Id, cancellationToken); @@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst), GoogleMapsUrl = primary.GoogleMapsUrl, Notes = linked ? primary.CompanyNotes : null, + AreaId = linked ? primary.CompanyAreaId : null, + AreaName = linked ? primary.CompanyAreaName : null, IsActive = primary.IsActive, TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs), Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician @@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = null, GoogleMapsUrl = company.GoogleMapsUrl, Notes = company.Notes, + AreaId = company.AreaId, + AreaName = company.Area?.Name, IsActive = true, TotalJobs = 0, Technicians = Array.Empty() @@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, - IReadOnlyCollection? jobBuckets = null) + IReadOnlyCollection? jobBuckets = null, + VendorAreaFilter? areaFilter = null) { var sourceRows = BuildDirectoryUnionBaseQuery(isActive) - .Select(BuildGroupSourceRowSelector(search, companies, trades, locations)); + .Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter)); var grouped = sourceRows .GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey }) @@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation SearchMatch = group.Max(row => row.SearchFlag), CompanyMatch = group.Max(row => row.CompanyFlag), TradeMatch = group.Max(row => row.TradeFlag), - LocationMatch = group.Max(row => row.LocationFlag) + LocationMatch = group.Max(row => row.LocationFlag), + AreaMatch = group.Max(row => row.AreaFlag) }); // Each facet selects the group when any status-eligible row matches it @@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation if (selectedLocations is { Length: > 0 }) grouped = grouped.Where(key => key.LocationMatch == 1); + if (areaFilter != null) + grouped = grouped.Where(key => key.AreaMatch == 1); + var selectedJobBuckets = jobBuckets? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) @@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation string? search, IReadOnlyCollection? companies, IReadOnlyCollection? trades, - IReadOnlyCollection? locations) + IReadOnlyCollection? locations, + VendorAreaFilter? areaFilter) { var row = Expression.Parameter(typeof(VendorDirectoryRow), "row"); @@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation var locationBody = selectedLocations is { Length: > 0 } ? BuildLocationMatchBody(row, selectedLocations) : null; + var areaBody = areaFilter == null + ? null + : BuildAreaMatchBody(row, areaFilter); var nameKey = Expression.Condition( Expression.NotEqual( @@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)), Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)), Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)), - Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))), + Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)), + Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))), row); } @@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation CompanyCity = v.Company != null ? v.Company.City : null, CompanyState = v.Company != null ? v.Company.State : null, CompanyNotes = v.Company != null ? v.Company.Notes : null, + CompanyAreaId = v.Company != null ? v.Company.AreaId : null, + CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null, ContactName = v.ContactName, Email = v.Email, Phone = v.Phone, @@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = v.TradeSpecialties, GoogleMapsUrl = v.GoogleMapsUrl, Notes = v.Company != null ? v.Company.Notes : null, + AreaId = v.Company != null ? v.Company.AreaId : null, IsActive = v.IsActive, TotalJobs = (int?)jobCount.TotalJobs ?? 0 }; @@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = null, GoogleMapsUrl = c.GoogleMapsUrl, Notes = c.Notes, + AreaId = c.AreaId, IsActive = true, TotalJobs = 0 }); @@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation return body!; } + // SH-278: OR within the Area facet. Selected ids match the company's stable + // AreaId; the Unassigned sentinel matches rows without an Area, which includes + // unlinked legacy vendors that have no VendorCompany at all. A filter with no + // resolvable ids and no Unassigned matches nobody. + private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter) + { + var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId)); + Expression? body = null; + + if (areaFilter.AreaIds.Count > 0) + { + var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray(); + body = OrElse(body, Expression.Call( + typeof(Enumerable), + nameof(Enumerable.Contains), + new[] { typeof(int?) }, + Expression.Constant(ids), + areaId)); + } + + if (areaFilter.IncludeUnassigned) + body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?)))); + + return body ?? Expression.Constant(false); + } + private static Expression BuildCompanyNameMatchBody( ParameterExpression row, IReadOnlyCollection selectedCompanies) diff --git a/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs index 45a7937..e2e6b62 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs @@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces { public interface IVendorCompanyRosterDataService { + // SH-278: true only for an existing, non-archived Area in the catalogue. + Task IsActiveAreaAsync(int areaId, CancellationToken cancellationToken); + // Loads the company roster from either a vendor row id and/or a company id, // returning the company plus all non-deleted technician rows. Task GetRosterAsync( diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 2e08912..f0002ba 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces // of technician Vendor.TradeSpecialties free text. Task> GetActiveTradesAsync(CancellationToken cancellationToken); + // SH-278: active (non-archived) Area catalogue rows for vendor facets and for + // resolving areas[n] filter ids. Archived Areas are excluded. + Task> GetActiveAreasAsync(CancellationToken cancellationToken); + // SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies, // with status semantics aligned to BuildDirectoryUnionQuery. Null returns all // companies; active includes companies with zero non-deleted technicians or at @@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + VendorAreaFilter? areaFilter = null, CancellationToken cancellationToken = default); Task AddAsync(Vendor vendor); Task UpdateAsync(Vendor vendor); diff --git a/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs index 33e1225..92b4a55 100644 --- a/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs +++ b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs @@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public byte[]? RowVersion { get; set; } public List Technicians { get; set; } = new(); } @@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: applied only when AreaIdProvided; null AreaId means Unassigned. + public bool AreaIdProvided { get; set; } + public int? AreaId { get; set; } public string? ActorUserId { get; set; } public List Technicians { get; set; } = new(); } @@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: applied only when AreaIdProvided; null AreaId means Unassigned. + public bool AreaIdProvided { get; set; } + public int? AreaId { get; set; } } // Raised by the roster data service when a technician removed from the snapshot diff --git a/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs index dff7be9..71eb5d0 100644 --- a/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs +++ b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs @@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models public string? TradeSpecialties { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } public bool IsActive { get; set; } public int TotalJobs { get; set; } } + // SH-278: Area facet filter for the company directory. AreaIds are already + // resolved against the active catalogue; IncludeUnassigned selects groups with no + // Area (including unlinked legacy vendors). An empty filter matches nobody. + public sealed class VendorAreaFilter + { + public IReadOnlyCollection AreaIds { get; init; } = Array.Empty(); + public bool IncludeUnassigned { get; init; } + } + // SH-281 company directory: per-row source for the grouped key query. Carries the // company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy // vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with @@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models public int CompanyFlag { get; set; } public int TradeFlag { get; set; } public int LocationFlag { get; set; } + public int AreaFlag { get; set; } } // SH-281: one row per company group after SQL grouping — the bounded key page. @@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models public int CompanyMatch { get; set; } public int TradeMatch { get; set; } public int LocationMatch { get; set; } + public int AreaMatch { get; set; } } // SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own @@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models public string? CompanyCity { get; set; } public string? CompanyState { get; set; } public string? CompanyNotes { get; set; } + public int? CompanyAreaId { get; set; } + public string? CompanyAreaName { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } @@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models public string? TradeSpecialties { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public bool IsActive { get; set; } public int TotalJobs { get; set; } public IReadOnlyList Technicians { get; set; } = Array.Empty(); diff --git a/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs index 3e20202..40d9a6d 100644 --- a/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace SeaHaven.Services.DTOs { public class VendorRosterDTO @@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public string? RowVersion { get; set; } public List Technicians { get; set; } = new(); } @@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: optional Area id; null leaves the new company Unassigned. Assigning + // an Area requires the Admin role. + public int? AreaId { get; set; } public List Technicians { get; set; } = new(); } @@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: an omitted areaId leaves the stored Area unchanged, so clients that + // predate Area never clear it. An explicit null clears it to Unassigned. + private int? _areaId; + public int? AreaId + { + get => _areaId; + set + { + _areaId = value; + AreaIdProvided = true; + } + } + + [JsonIgnore] + public bool AreaIdProvided { get; private set; } public List Technicians { get; set; } = new(); } @@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId. + private int? _areaId; + public int? AreaId + { + get => _areaId; + set + { + _areaId = value; + AreaIdProvided = true; + } + } + + [JsonIgnore] + public bool AreaIdProvided { get; private set; } + } + + // SH-278: raised when a caller without the Admin role tries to change a vendor + // company's Area. Mapped to a stable 403 by the API. + public sealed class VendorAreaAssignmentForbiddenException : Exception + { + public VendorAreaAssignmentForbiddenException() + : base("Only administrators can assign a vendor company Area.") + { + } } } diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 12fb5b8..0c74465 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs public string? TradeSpecialties { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public int TotalJobs { get; set; } public bool IsActive { get; set; } public List Technicians { get; set; } = new(); diff --git a/SeaHaven.Services/DTOs/VendorPortalDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs index 8af1cfa..714a32f 100644 --- a/SeaHaven.Services/DTOs/VendorPortalDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs @@ -59,7 +59,21 @@ namespace SeaHaven.Services.DTOs { public IEnumerable Companies { get; set; } = Enumerable.Empty(); public IEnumerable Trades { get; set; } = Enumerable.Empty(); + public IEnumerable Areas { get; set; } = Enumerable.Empty(); public IEnumerable Locations { get; set; } = Enumerable.Empty(); public IEnumerable JobBuckets { get; set; } = Enumerable.Empty(); } + + // SH-278: one active Area option. Id is the stable areas[n] filter value. + public class VendorFacetAreaDTO + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + // SH-278: wire values accepted by the vendor directory areas[n] filter besides ids. + public static class VendorAreaFilterValues + { + public const string Unassigned = "__unassigned__"; + } } diff --git a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs index c5b7676..0cd1169 100644 --- a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs +++ b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs @@ -6,6 +6,7 @@ using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; +using System.Security.Claims; namespace SeaHaven.Services.Implementation { @@ -13,6 +14,8 @@ namespace SeaHaven.Services.Implementation { private const int MaxNotesLength = 500; private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters."; + private const string AdminRole = "Admin"; + private const string AreaNotFoundMessage = "Area was not found."; private readonly IVendorCompanyRosterDataService _rosterDataService; @@ -39,6 +42,7 @@ namespace SeaHaven.Services.Implementation public async Task CreateRosterAsync( CreateVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken) { EnsureAuthenticated(userId); @@ -60,6 +64,13 @@ namespace SeaHaven.Services.Implementation "Technician ids are not allowed when creating a vendor company.") }); + await EnsureAreaAssignmentAllowedAsync( + caller, + dto.AreaId, + currentAreaId: null, + nameof(CreateVendorRosterDTO.AreaId), + cancellationToken); + var writeModel = new VendorCompanyRosterWriteModel { Name = dto.Name, @@ -71,6 +82,8 @@ namespace SeaHaven.Services.Implementation Zip = dto.Zip, GoogleMapsUrl = dto.GoogleMapsUrl, Notes = dto.Notes, + AreaIdProvided = true, + AreaId = dto.AreaId, ActorUserId = userId, Technicians = MapTechnicians(dto.Technicians) }; @@ -83,6 +96,7 @@ namespace SeaHaven.Services.Implementation int companyId, ReconcileVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken) { EnsureAuthenticated(userId); @@ -101,6 +115,14 @@ namespace SeaHaven.Services.Implementation // Mismatched ids: every non-null technician id must belong to this company. await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken); + if (dto.AreaIdProvided) + await EnsureAreaChangeAllowedAsync( + companyId, + caller, + dto.AreaId, + nameof(ReconcileVendorRosterDTO.AreaId), + cancellationToken); + var writeModel = new VendorCompanyRosterWriteModel { CompanyId = companyId, @@ -114,6 +136,8 @@ namespace SeaHaven.Services.Implementation Zip = dto.Zip, GoogleMapsUrl = dto.GoogleMapsUrl, Notes = dto.Notes, + AreaIdProvided = dto.AreaIdProvided, + AreaId = dto.AreaId, ActorUserId = userId, Technicians = MapTechnicians(dto.Technicians) }; @@ -126,6 +150,7 @@ namespace SeaHaven.Services.Implementation int companyId, AddTechniciansVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken) { EnsureAuthenticated(userId); @@ -167,6 +192,14 @@ namespace SeaHaven.Services.Implementation if (failures.Count > 0) throw new ValidationException(failures); + if (companyFields is { AreaIdProvided: true }) + await EnsureAreaChangeAllowedAsync( + companyId, + caller, + companyFields.AreaId, + $"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}", + cancellationToken); + // The contact-group invariant ("at least one company phone or email") is // preserved by construction: blank company fields map to null ("unchanged"), // so this endpoint can only set valid phone/email values, never clear them. @@ -184,6 +217,50 @@ namespace SeaHaven.Services.Implementation return MapToDTO(saved); } + // SH-278: Area assignment is Admin-only. A payload that leaves the stored Area + // unchanged is allowed for any authenticated caller, so non-admin users can + // still save other company fields while round-tripping the current Area. + private async Task EnsureAreaChangeAllowedAsync( + int companyId, + ClaimsPrincipal caller, + int? requestedAreaId, + string propertyName, + CancellationToken cancellationToken) + { + var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken) + ?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found."); + + await EnsureAreaAssignmentAllowedAsync( + caller, + requestedAreaId, + roster.AreaId, + propertyName, + cancellationToken); + } + + private async Task EnsureAreaAssignmentAllowedAsync( + ClaimsPrincipal caller, + int? requestedAreaId, + int? currentAreaId, + string propertyName, + CancellationToken cancellationToken) + { + if (requestedAreaId == currentAreaId) + return; + + if (caller?.IsInRole(AdminRole) != true) + throw new VendorAreaAssignmentForbiddenException(); + + if (requestedAreaId is int areaId + && (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken))) + { + throw new ValidationException(new[] + { + new ValidationFailure(propertyName, AreaNotFoundMessage) + }); + } + } + private static void EnsureAuthenticated(string userId) { if (string.IsNullOrWhiteSpace(userId)) @@ -290,7 +367,8 @@ namespace SeaHaven.Services.Implementation || !string.IsNullOrWhiteSpace(fields.State) || !string.IsNullOrWhiteSpace(fields.Zip) || !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) - || !string.IsNullOrWhiteSpace(fields.Notes); + || !string.IsNullOrWhiteSpace(fields.Notes) + || fields.AreaIdProvided; private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) => model.Name == null @@ -301,7 +379,8 @@ namespace SeaHaven.Services.Implementation && model.State == null && model.Zip == null && model.GoogleMapsUrl == null - && model.Notes == null; + && model.Notes == null + && !model.AreaIdProvided; private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields( VendorRosterCompanyFieldsDTO fields, @@ -344,7 +423,9 @@ namespace SeaHaven.Services.Implementation State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State, Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip, GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl, - Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes + Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes, + AreaIdProvided = fields.AreaIdProvided, + AreaId = fields.AreaId }; } @@ -443,6 +524,8 @@ namespace SeaHaven.Services.Implementation Zip = roster.Zip, GoogleMapsUrl = roster.GoogleMapsUrl, Notes = roster.Notes, + AreaId = roster.AreaId, + AreaName = roster.AreaName, RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion), Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO { diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 5e4ce40..2d715a3 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.Logging; using Microsoft.Extensions.Options; +using System.Globalization; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.Configuration; @@ -160,10 +161,12 @@ namespace SeaHaven.Services.Implementation IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + IReadOnlyCollection? areas = null, CancellationToken cancellationToken = default) { var normalizedPage = Math.Max(1, page); var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize); + var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken); var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync( normalizedPage, normalizedPageSize, @@ -173,6 +176,7 @@ namespace SeaHaven.Services.Implementation trades, locations, jobBuckets, + areaFilter, cancellationToken); return new PagedResult @@ -184,6 +188,47 @@ namespace SeaHaven.Services.Implementation }; } + // SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects + // companies without an Area; other values must be stable positive integer ids + // of active catalogue rows. Display names, unknown ids and archived ids never + // error — they simply match nobody. Null means no Area filter was requested. + private async Task ResolveAreaFilterAsync( + IReadOnlyCollection? areas, + CancellationToken cancellationToken) + { + var values = areas? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .ToList(); + + if (values is not { Count: > 0 }) + return null; + + var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal); + var requestedIds = new HashSet(); + foreach (var value in values) + { + if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0) + requestedIds.Add(id); + } + + IReadOnlyCollection activeIds = Array.Empty(); + if (requestedIds.Count > 0) + { + var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken); + activeIds = activeAreas + .Select(area => area.Id) + .Where(requestedIds.Contains) + .ToArray(); + } + + return new VendorAreaFilter + { + AreaIds = activeIds, + IncludeUnassigned = includeUnassigned + }; + } + private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new() { Id = group.Id, @@ -201,6 +246,8 @@ namespace SeaHaven.Services.Implementation TradeSpecialties = group.TradeSpecialties, GoogleMapsUrl = group.GoogleMapsUrl, Notes = group.Notes, + AreaId = group.AreaId, + AreaName = group.AreaName, IsActive = group.IsActive, TotalJobs = group.TotalJobs, Technicians = group.Technicians.Select(MapToTechnician).ToList() @@ -731,6 +778,7 @@ namespace SeaHaven.Services.Implementation // populated even on an empty database and independent of the status filter. var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken); var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken); + var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken); var companyFacets = companies .Where(c => !string.IsNullOrWhiteSpace(c.Name)) @@ -772,10 +820,19 @@ namespace SeaHaven.Services.Implementation .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label); + // SH-278: the active Area catalogue, A–Z case-insensitive, independent of the + // status filter and of whether any company currently uses an Area. Clients + // prepend the "__unassigned__" option themselves. + var areas = activeAreas + .OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase) + .Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name }) + .ToList(); + return new VendorFacetsDTO { Companies = companyFacets, Trades = trades, + Areas = areas, Locations = locations, JobBuckets = new[] { diff --git a/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs index 27d9d79..5b48cc0 100644 --- a/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs +++ b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces @@ -13,18 +14,21 @@ namespace SeaHaven.Services.Interfaces Task CreateRosterAsync( CreateVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken); Task ReconcileRosterAsync( int companyId, ReconcileVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken); Task AddTechniciansAsync( int companyId, AddTechniciansVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 74782af..185cb29 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -41,6 +41,7 @@ namespace SeaHaven.Services.Interfaces IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + IReadOnlyCollection? areas = null, CancellationToken cancellationToken = default); Task CreateVendorAsync(CreateVendorDTO dto, string userId); Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);