From 06f9450485c2aa10a3068fd3d295f86f782bed0e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 16:18:46 -0300 Subject: [PATCH 01/22] feat(services): add global services registry --- .../ServicesRegistryControllerTests.cs | 185 + .../ServicesRegistryServiceTests.cs | 364 ++ .../Controllers/ServicesRegistryController.cs | 110 + .../Auth/ApplicationDbContext.cs | 49 + ...6185443_SH303_ServicesRegistry.Designer.cs | 4074 +++++++++++++++++ .../20260916185443_SH303_ServicesRegistry.cs | 162 + .../ApplicationDbContextModelSnapshot.cs | 141 + Data.SeaHavenIndustries/Models/Service.cs | 29 + .../ServicesRegistryDataService.cs | 130 + .../IServicesRegistryDataService.cs | 39 + .../DTOs/ServicesRegistryDTOs.cs | 38 + .../Exceptions/ServicesRegistryExceptions.cs | 12 + .../Implementation/ServicesRegistryService.cs | 226 + .../Interfaces/IServicesRegistryService.cs | 19 + .../SH303ServicesRegistryMigrationTests.cs | 316 ++ 15 files changed, 5894 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.cs create mode 100644 Data.SeaHavenIndustries/Models/Service.cs create mode 100644 SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs create mode 100644 SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs create mode 100644 SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs create mode 100644 SeaHaven.Services/Implementation/ServicesRegistryService.cs create mode 100644 SeaHaven.Services/Interfaces/IServicesRegistryService.cs create mode 100644 SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs new file mode 100644 index 0000000..c1d6cf0 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs @@ -0,0 +1,185 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class ServicesRegistryControllerTests +{ + private static ServicesRegistryController NewController(Mock service, params string[] roles) + { + var controller = new ServicesRegistryController(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")) + } + } + }; + return controller; + } + + private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) => + (result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto; + + [Fact] + public async Task GetAll_ReturnsOkWithServices() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Name = "Leak", IsActive = true } }); + + var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as IEnumerable).Should().ContainSingle(s => s.Name == "Leak"); + } + + [Fact] + public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny())) + .Throws(new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency.")); + + var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid"); + ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency."); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFoundWithCode() + { + var service = new Mock(); + service.Setup(s => s.GetByIdAsync(9, It.IsAny())) + .ReturnsAsync((ServiceDto?)null); + + var result = await NewController(service, "Admin").GetById(9, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Create_PassesCallerAndReturnsCreated() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true }); + + var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var created = result.Should().BeOfType().Subject; + created.StatusCode.Should().Be(StatusCodes.Status201Created); + (created.Value as ServiceDto)!.Id.Should().Be(42); + service.Verify(s => s.CreateAsync( + It.Is(p => p.IsInRole("Scheduler")), + It.Is(i => i.Name == "Leak"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.")); + + var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenDuplicateName_Returns400WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.")); + + var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_ReturnsOkWithUpdatedService() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 7, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" }); + + var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as ServiceDto)!.Name.Should().Be("Renamed"); + } + + [Fact] + public async Task Update_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 404, It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Deactivate_ReturnsOk() + { + var service = new Mock(); + + var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(s => s.DeactivateAsync( + It.Is(p => p.IsInRole("Admin")), 3, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Deactivate_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 3, It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.")); + + var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Deactivate_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 404, It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None); + + result.Should().BeOfType(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs new file mode 100644 index 0000000..6d7086d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs @@ -0,0 +1,364 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Global Services registry behavior. The registry has no tenant scope +// (no AccountId), authorization is enforced at the service boundary, and the +// only allowed supported work-order types are PM, Reactive, and Emergency. +public class ServicesRegistryServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ServicesRegistryService NewService(ApplicationDbContext ctx) => + new(new ServicesRegistryDataService(ctx)); + + private static ClaimsPrincipal Principal(params string[] roles) => + new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")); + + private static ClaimsPrincipal Admin() => Principal("Admin"); + private static ClaimsPrincipal Scheduler() => Principal("Scheduler"); + + private static ServiceInput Input( + string name = "Leak", + string? trade = "Plumbing & Water Systems", + string? iconKey = "plumbing-water-systems", + bool requiresDoc = false, + int? templateId = null, + List? types = null) => new() + { + Name = name, + Trade = trade, + IconKey = iconKey, + RequiresCompletionDocument = requiresDoc, + CompletionDocTemplateId = templateId, + SupportedWorkOrderTypes = types ?? new List + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + } + }; + + private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) => + new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted }; + + [Fact] + public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes() + { + using var ctx = NewContext(); + + var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None); + + dto.Id.Should().BeGreaterThan(0); + dto.Name.Should().Be("Leak Detection"); + dto.Trade.Should().Be("Plumbing & Water Systems"); + dto.IconKey.Should().Be("plumbing-water-systems"); + dto.IsActive.Should().BeTrue("new services are always active"); + dto.CompletionDocTemplate.Should().BeNull(); + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }); + + var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single(); + row.NormalizedName.Should().Be("leak detection"); + } + + [Fact] + public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes() + { + using var ctx = NewContext(); + + var input = Input(types: null); + input.SupportedWorkOrderTypes = null; + + var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None); + var ex = (await act.Should().ThrowAsync()).Subject.Single(); + ex.Code.Should().Be("NameRequired"); + } + + [Fact] + public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("TradeInvalid"); + } + + [Theory] + [InlineData(" hvac ")] + [InlineData("HVAC")] + [InlineData("hvac")] + public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade) + { + using var ctx = NewContext(); + var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None); + dto.Trade.Should().Be("HVAC"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("IconKeyInvalid"); + } + + [Fact] + public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing() + { + using var ctx = NewContext(); + var service = NewService(ctx); + await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter() + { + using var ctx = NewContext(); + var service = NewService(ctx); + // Distinct actors with different role sets still share one global registry. + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + + var all = await service.GetAllAsync(null, null, CancellationToken.None); + all.Should().ContainSingle(s => s.Id == first.Id); + typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions"); + } + + [Fact] + public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid() + { + using var ctx = NewContext(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + ctx.CompletionDocTemplates.Add(Template("Inactive", active: false)); + ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true)); + ctx.SaveChanges(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + } + + [Fact] + public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeTrue(); + dto.CompletionDocTemplate.Should().NotBeNull(); + dto.CompletionDocTemplate!.Id.Should().Be(template.Id); + dto.CompletionDocTemplate.Name.Should().Be("Completion Packet"); + } + + [Fact] + public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeFalse(); + dto.CompletionDocTemplate.Should().BeNull(); + ctx.Services.Single().CompletionDocTemplateId.Should().BeNull(); + } + + [Theory] + [InlineData(WorkOrderType.PO)] + [InlineData(WorkOrderType.Project)] + [InlineData(WorkOrderType.Inspection)] + [InlineData(WorkOrderType.AddOn)] + [InlineData(WorkOrderType.Other)] + public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported) + { + using var ctx = NewContext(); + var input = Input(types: new List { WorkOrderType.PM, unsupported }); + var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task Update_RewritesFieldsButPreservesActiveState() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var updated = await service.UpdateAsync( + Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None); + + updated.Name.Should().Be("Leak Repair"); + updated.Trade.Should().Be("HVAC"); + updated.IconKey.Should().Be("hvac"); + updated.IsActive.Should().BeFalse("update preserves the persisted active state"); + } + + [Fact] + public async Task Update_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None); + + var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().Name.Should().Be("Leak"); + } + + [Fact] + public async Task Deactivate_SoftStateChangeOnly_PreservesRow() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + ctx.Services.Should().HaveCount(1, "deactivation never hard deletes"); + var byId = await service.GetByIdAsync(created.Id, CancellationToken.None); + byId.Should().NotBeNull(); + byId!.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Deactivate_WhenScheduler_ThrowsForbidden() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Deactivate_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task GetAll_FiltersByActiveStateAndWorkOrderType() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List { WorkOrderType.PM }), CancellationToken.None); + var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List { WorkOrderType.Reactive }), CancellationToken.None); + await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None); + + (await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { reactive.Id }); + + (await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNull() + { + using var ctx = NewContext(); + (await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs new file mode 100644 index 0000000..fe66268 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs @@ -0,0 +1,110 @@ +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/services")] + public class ServicesRegistryController : Controller + { + private readonly IServicesRegistryService _servicesRegistryService; + + public ServicesRegistryController(IServicesRegistryService servicesRegistryService) + { + _servicesRegistryService = servicesRegistryService; + } + + [HttpGet] + public async Task GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + try + { + var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken); + return Ok(services); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("{id}")] + public async Task GetById(int id, CancellationToken cancellationToken) + { + var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken); + if (service == null) + return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." }); + + return Ok(service); + } + + [HttpPost] + public async Task Create([FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken); + return StatusCode(StatusCodes.Status201Created, created); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPut("{id}")] + public async Task Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken); + return Ok(updated); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id}/deactivate")] + public async Task Deactivate(int id, CancellationToken cancellationToken) + { + try + { + await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken); + return Ok(new { message = "Service deactivated" }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + } + + private IActionResult MapForbidden(ServicesRegistryValidationException ex) + { + return StatusCode(StatusCodes.Status403Forbidden, + new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } +} diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a5256dc..8426000 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -206,6 +206,53 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(t => t.NormalizedName) .IsUnique(); + + // Bounded lengths keep the unique NormalizedName key SQL + // Server-indexable (nvarchar(max) is not). + builder.Entity() + .Property(s => s.Name) + .HasMaxLength(200); + + builder.Entity() + .Property(s => s.NormalizedName) + .HasMaxLength(200); + + builder.Entity() + .HasIndex(s => s.NormalizedName) + .IsUnique(); + + builder.Entity() + .Property(s => s.Trade) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.Trade); + + builder.Entity() + .Property(s => s.IconKey) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.IsActive); + + builder.Entity() + .HasOne(s => s.CompletionDocTemplate) + .WithMany() + .HasForeignKey(s => s.CompletionDocTemplateId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(s => s.CompletionDocTemplateId); + + builder.Entity() + .HasIndex(t => new { t.ServiceId, t.WorkOrderType }) + .IsUnique(); + + builder.Entity() + .HasOne(t => t.Service) + .WithMany(s => s.SupportedWorkOrderTypes) + .HasForeignKey(t => t.ServiceId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -241,6 +288,8 @@ namespace Data.SeaHavenIndustries public DbSet DispatchUpliftRequests { get; set; } public DbSet TaskListTemplates { get; set; } public DbSet TaskListTemplateItems { get; set; } + public DbSet Services { get; set; } + public DbSet ServiceWorkOrderTypes { get; set; } // New DbSets for added entities public DbSet Accounts { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs new file mode 100644 index 0000000..15ed6d7 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs @@ -0,0 +1,4074 @@ +// +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("20260916185443_SH303_ServicesRegistry")] + partial class SH303_ServicesRegistry + { + /// + 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.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.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", 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("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + 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("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("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.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.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.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + 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/20260916185443_SH303_ServicesRegistry.cs b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.cs new file mode 100644 index 0000000..41fe63d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH303_ServicesRegistry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Services", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Trade = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + IconKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + RequiresCompletionDocument = table.Column(type: "bit", nullable: false), + CompletionDocTemplateId = table.Column(type: "int", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Services", x => x.Id); + table.ForeignKey( + name: "FK_Services_CompletionDocTemplates_CompletionDocTemplateId", + column: x => x.CompletionDocTemplateId, + principalTable: "CompletionDocTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ServiceWorkOrderTypes", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ServiceId = table.Column(type: "int", nullable: false), + WorkOrderType = table.Column(type: "int", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ServiceWorkOrderTypes", x => x.Id); + table.ForeignKey( + name: "FK_ServiceWorkOrderTypes_Services_ServiceId", + column: x => x.ServiceId, + principalTable: "Services", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Services_CompletionDocTemplateId", + table: "Services", + column: "CompletionDocTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_Services_IsActive", + table: "Services", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_Services_NormalizedName", + table: "Services", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Services_Trade", + table: "Services", + column: "Trade"); + + migrationBuilder.CreateIndex( + name: "IX_ServiceWorkOrderTypes_ServiceId_WorkOrderType", + table: "ServiceWorkOrderTypes", + columns: new[] { "ServiceId", "WorkOrderType" }, + unique: true); + + // Additive seed: one active service per active Problem dropdown + // value already stored in the database (never the prototype's demo + // leaves). Idempotent via NormalizedName NOT EXISTS; dropdown rows are + // only read, never altered. Repeated source values collapse onto a + // deterministic MIN(Value) representative per normalized key. + migrationBuilder.Sql( + """ + INSERT INTO [Services] ([Name], [NormalizedName], [Trade], [IconKey], [RequiresCompletionDocument], [CompletionDocTemplateId], [IsActive], [IsDeleted], [createdby]) + SELECT seeded.[Name], seeded.[NormalizedName], seeded.[Trade], + CASE seeded.[Trade] + WHEN N'Plumbing & Water Systems' THEN N'plumbing-water-systems' + WHEN N'Electrical' THEN N'electrical' + WHEN N'HVAC' THEN N'hvac' + WHEN N'Fire & Life Safety' THEN N'fire-life-safety' + ELSE N'general-building-handyman' + END, + 0, NULL, 1, 0, N'sh303-migration' + FROM ( + SELECT agg.[Name], agg.[NormalizedName], + CASE + WHEN agg.[Name] IN (N'Leak', N'Clogged') THEN N'Plumbing & Water Systems' + WHEN agg.[Name] = N'No Power' THEN N'Electrical' + WHEN agg.[Name] IN (N'Not Cooling', N'Not Heating') THEN N'HVAC' + WHEN agg.[Name] = N'Alarm Fault' THEN N'Fire & Life Safety' + ELSE N'General Building & Handyman' + END AS [Trade] + FROM ( + SELECT MIN(src.[Value]) AS [Name], + LOWER(LTRIM(RTRIM(src.[Value]))) AS [NormalizedName] + FROM [DropdownOptions] src + WHERE src.[Category] = N'Problem' + AND src.[IsActive] = 1 + GROUP BY LOWER(LTRIM(RTRIM(src.[Value]))) + ) agg + ) seeded + WHERE NOT EXISTS ( + SELECT 1 FROM [Services] s WHERE s.[NormalizedName] = seeded.[NormalizedName] + ); + + INSERT INTO [ServiceWorkOrderTypes] ([ServiceId], [WorkOrderType], [IsDeleted]) + SELECT s.[Id], types.[WorkOrderType], 0 + FROM [Services] s + CROSS JOIN (VALUES (2), (6), (3)) AS types([WorkOrderType]) + WHERE s.[createdby] = N'sh303-migration' + AND NOT EXISTS ( + SELECT 1 FROM [ServiceWorkOrderTypes] x + WHERE x.[ServiceId] = s.[Id] + AND x.[WorkOrderType] = types.[WorkOrderType] + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServiceWorkOrderTypes"); + + migrationBuilder.DropTable( + name: "Services"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 7bf6f39..04dfa9e 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1762,6 +1762,121 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("Regions"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", 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("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => { b.Property("Id") @@ -3521,6 +3636,27 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Quotes"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => { b.HasOne("Data.SeaHavenIndustries.Locations", "Location") @@ -3889,6 +4025,11 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Employees"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => { b.Navigation("Items"); diff --git a/Data.SeaHavenIndustries/Models/Service.cs b/Data.SeaHavenIndustries/Models/Service.cs new file mode 100644 index 0000000..adba3f4 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/Service.cs @@ -0,0 +1,29 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + // Global (tenant-scope free) Services registry. Services are never + // hard deleted; deactivation is a soft IsActive state change so historical + // work-order references remain resolvable. + public class Service : FullAuditEntity + { + public string Name { get; set; } = ""; + public string NormalizedName { get; set; } = ""; + public string Trade { get; set; } = ""; + public string IconKey { get; set; } = ""; + public bool RequiresCompletionDocument { get; set; } + public int? CompletionDocTemplateId { get; set; } + public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; } + public bool IsActive { get; set; } = true; + public virtual ICollection SupportedWorkOrderTypes { get; set; } = new List(); + } + + // Supported work-order types (PM, Reactive, Emergency) stored as an + // explicitly queryable relation so work-order features can filter by type. + public class ServiceWorkOrderType : FullAuditEntity + { + public int ServiceId { get; set; } + public virtual Service? Service { get; set; } + public WorkOrderType WorkOrderType { get; set; } + } +} diff --git a/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs b/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs new file mode 100644 index 0000000..b6acd06 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs @@ -0,0 +1,130 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class ServicesRegistryDataService : IServicesRegistryDataService + { + private readonly ApplicationDbContext _context; + + public ServicesRegistryDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + var query = _context.Services + .AsNoTracking() + .Where(s => s.IsDeleted != true); + + if (isActive.HasValue) + query = query.Where(s => s.IsActive == isActive.Value); + + if (workOrderType.HasValue) + query = query.Where(s => s.SupportedWorkOrderTypes.Any(t => t.WorkOrderType == workOrderType.Value)); + + var rows = await query + .OrderBy(s => s.Name) + .Select(s => new ServiceRegistryRow + { + Id = s.Id, + Name = s.Name, + Trade = s.Trade, + IconKey = s.IconKey, + RequiresCompletionDocument = s.RequiresCompletionDocument, + IsActive = s.IsActive, + CompletionDocTemplateId = s.CompletionDocTemplateId, + CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null, + SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList() + }) + .ToListAsync(cancellationToken); + return rows; + } + + public async Task GetServiceRowByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.Services.AsNoTracking() + .Where(s => s.Id == id && s.IsDeleted != true) + .Select(s => new ServiceRegistryRow + { + Id = s.Id, + Name = s.Name, + Trade = s.Trade, + IconKey = s.IconKey, + RequiresCompletionDocument = s.RequiresCompletionDocument, + IsActive = s.IsActive, + CompletionDocTemplateId = s.CompletionDocTemplateId, + CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null, + SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetServiceForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.Services + .Include(s => s.SupportedWorkOrderTypes) + .FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken); + } + + public async Task NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken) + { + return await _context.Services + .AnyAsync(s => s.IsDeleted != true + && s.NormalizedName == normalizedName + && (excludeServiceId == null || s.Id != excludeServiceId), + cancellationToken); + } + + public async Task GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken) + { + return await _context.CompletionDocTemplates + .Where(t => t.Id == templateId && t.IsActive && t.IsDeleted != true) + .Select(t => new LinkedTemplateSummary { Id = t.Id, Name = t.Name }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task AddServiceAsync(Service service, CancellationToken cancellationToken) + { + await _context.Services.AddAsync(service, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return service; + } + + public async Task SaveServiceAsync(Service service, IReadOnlyCollection supportedWorkOrderTypes, CancellationToken cancellationToken) + { + var desired = supportedWorkOrderTypes.Distinct().ToList(); + var current = service.SupportedWorkOrderTypes ?? new List(); + + var stale = current.Where(t => !desired.Contains(t.WorkOrderType)).ToList(); + _context.ServiceWorkOrderTypes.RemoveRange(stale); + + var existingTypes = current.Select(t => t.WorkOrderType).ToHashSet(); + foreach (var type in desired.Where(t => !existingTypes.Contains(t))) + { + _context.ServiceWorkOrderTypes.Add(new ServiceWorkOrderType + { + ServiceId = service.Id, + WorkOrderType = type + }); + } + + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeactivateServiceAsync(int id, CancellationToken cancellationToken) + { + var service = await _context.Services + .FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken); + if (service == null) + return false; + + service.IsActive = false; + await _context.SaveChangesAsync(cancellationToken); + return true; + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs b/SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs new file mode 100644 index 0000000..a09e465 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs @@ -0,0 +1,39 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + // Read-side projection for the global Services registry. The linked + // completion template is projected to id/name only — full template + // entities are never materialized. + public class ServiceRegistryRow + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Trade { get; set; } = ""; + public string IconKey { get; set; } = ""; + public bool RequiresCompletionDocument { get; set; } + public bool IsActive { get; set; } + public int? CompletionDocTemplateId { get; set; } + public string? CompletionDocTemplateName { get; set; } + public List SupportedWorkOrderTypes { get; set; } = new(); + } + + public class LinkedTemplateSummary + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + public interface IServicesRegistryDataService + { + Task> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken); + Task GetServiceRowByIdAsync(int id, CancellationToken cancellationToken); + Task GetServiceForUpdateAsync(int id, CancellationToken cancellationToken); + Task NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken); + Task GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken); + Task AddServiceAsync(Service service, CancellationToken cancellationToken); + Task SaveServiceAsync(Service service, IReadOnlyCollection supportedWorkOrderTypes, CancellationToken cancellationToken); + Task DeactivateServiceAsync(int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs b/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs new file mode 100644 index 0000000..70fe43b --- /dev/null +++ b/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs @@ -0,0 +1,38 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class ServiceDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Trade { get; set; } = ""; + public string IconKey { get; set; } = ""; + public bool RequiresCompletionDocument { get; set; } + public LinkedTemplateSummaryDto? CompletionDocTemplate { get; set; } + public bool IsActive { get; set; } + public List SupportedWorkOrderTypes { get; set; } = new(); + } + + public class LinkedTemplateSummaryDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + public class ServiceInput + { + public string? Name { get; set; } + public string? Trade { get; set; } + public string? IconKey { get; set; } + public bool RequiresCompletionDocument { get; set; } + public int? CompletionDocTemplateId { get; set; } + public List? SupportedWorkOrderTypes { get; set; } + } + + public class ServicesRegistryValidationErrorDto + { + public string Code { get; set; } = ""; + public string Message { get; set; } = ""; + } +} diff --git a/SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs b/SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs new file mode 100644 index 0000000..bbec299 --- /dev/null +++ b/SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs @@ -0,0 +1,12 @@ +namespace SeaHaven.Services.Exceptions +{ + public class ServicesRegistryValidationException : Exception + { + public ServicesRegistryValidationException(string code, string message) : base(message) + { + Code = code; + } + + public string Code { get; } + } +} diff --git a/SeaHaven.Services/Implementation/ServicesRegistryService.cs b/SeaHaven.Services/Implementation/ServicesRegistryService.cs new file mode 100644 index 0000000..79e2dd9 --- /dev/null +++ b/SeaHaven.Services/Implementation/ServicesRegistryService.cs @@ -0,0 +1,226 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class ServicesRegistryService : IServicesRegistryService + { + // Product contract: the registry only accepts the three work-order types + // the product currently supports. Any other WorkOrderType is rejected. + public static readonly IReadOnlySet AllowedWorkOrderTypes = + new HashSet { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }; + + private static readonly WorkOrderType[] DefaultSupportedWorkOrderTypes = + { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }; + + public const string SchedulerRole = "Scheduler"; + public const string AdminRole = "Admin"; + + private const int NameMaxLength = 200; + private const int IconKeyMaxLength = 64; + + private readonly IServicesRegistryDataService _dataService; + + public ServicesRegistryService(IServicesRegistryDataService dataService) + { + _dataService = dataService; + } + + public async Task> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + ValidateWorkOrderTypeFilter(workOrderType); + var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken); + return rows.Select(MapToDto).ToList(); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken); + return row == null ? null : MapToDto(row); + } + + public async Task CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken) + { + EnsureCanMutate(user); + + var name = RequireValidName(input.Name); + var trade = RequireCanonicalTrade(input.Trade); + var iconKey = RequireValidIconKey(input.IconKey); + var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes); + var template = await ResolveTemplateLinkAsync(input, cancellationToken); + + if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), null, cancellationToken)) + throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."); + + var service = new Service + { + Name = name, + NormalizedName = TradeCatalog.Normalize(name), + Trade = trade, + IconKey = iconKey, + RequiresCompletionDocument = input.RequiresCompletionDocument, + CompletionDocTemplateId = template?.Id, + IsActive = true, + SupportedWorkOrderTypes = supportedTypes + .Select(t => new ServiceWorkOrderType { WorkOrderType = t }) + .ToList() + }; + + await _dataService.AddServiceAsync(service, cancellationToken); + + var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken); + return MapToDto(row!); + } + + public async Task UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken) + { + EnsureCanMutate(user); + + var service = await _dataService.GetServiceForUpdateAsync(id, cancellationToken); + if (service == null || service.IsDeleted == true) + throw new ServicesRegistryValidationException("NotFound", "Service not found."); + + var name = RequireValidName(input.Name); + var trade = RequireCanonicalTrade(input.Trade); + var iconKey = RequireValidIconKey(input.IconKey); + var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes); + var template = await ResolveTemplateLinkAsync(input, cancellationToken); + + if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), id, cancellationToken)) + throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."); + + service.Name = name; + service.NormalizedName = TradeCatalog.Normalize(name); + service.Trade = trade; + service.IconKey = iconKey; + service.RequiresCompletionDocument = input.RequiresCompletionDocument; + service.CompletionDocTemplateId = template?.Id; + + await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken); + + var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken); + return MapToDto(row!); + } + + public async Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken) + { + if (user is null || !user.IsInRole(AdminRole)) + throw new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."); + + var deactivated = await _dataService.DeactivateServiceAsync(id, cancellationToken); + if (!deactivated) + throw new ServicesRegistryValidationException("NotFound", "Service not found."); + } + + private static void EnsureCanMutate(ClaimsPrincipal user) + { + if (user is null || !(user.IsInRole(SchedulerRole) || user.IsInRole(AdminRole))) + throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."); + } + + private static string RequireValidName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ServicesRegistryValidationException("NameRequired", "Service name is required."); + + var trimmed = name.Trim(); + if (trimmed.Length > NameMaxLength) + throw new ServicesRegistryValidationException("NameInvalid", $"Service name cannot exceed {NameMaxLength} characters."); + + return trimmed; + } + + private static string RequireCanonicalTrade(string? trade) + { + if (string.IsNullOrWhiteSpace(trade)) + throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values."); + + var normalized = TradeCatalog.Normalize(trade); + var canonical = TradeCatalog.CanonicalTrades + .FirstOrDefault(t => TradeCatalog.Normalize(t) == normalized); + + if (canonical == null) + throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values."); + + return canonical; + } + + private static string RequireValidIconKey(string? iconKey) + { + if (string.IsNullOrWhiteSpace(iconKey)) + throw new ServicesRegistryValidationException("IconKeyInvalid", "Icon key is required."); + + var trimmed = iconKey.Trim(); + if (trimmed.Length > IconKeyMaxLength) + throw new ServicesRegistryValidationException("IconKeyInvalid", $"Icon key cannot exceed {IconKeyMaxLength} characters."); + + return trimmed; + } + + private static List RequireValidSupportedTypes(IEnumerable? supportedTypes) + { + // The registry form does not expose type categories yet. Until it + // does, an omitted or empty category list keeps a service available + // to the three supported work-order types. + var types = supportedTypes?.Distinct().ToList(); + if (types == null || types.Count == 0) + return DefaultSupportedWorkOrderTypes.ToList(); + + if (types.Any(t => !AllowedWorkOrderTypes.Contains(t))) + throw new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", + "Supported work order types may only include PM, Reactive, or Emergency."); + + return types; + } + + private static void ValidateWorkOrderTypeFilter(WorkOrderType? workOrderType) + { + if (workOrderType.HasValue && !AllowedWorkOrderTypes.Contains(workOrderType.Value)) + throw new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", + "Supported work order types may only include PM, Reactive, or Emergency."); + } + + private async Task ResolveTemplateLinkAsync(ServiceInput input, CancellationToken cancellationToken) + { + // Toggle off clears/ignores any template link. + if (!input.RequiresCompletionDocument || input.CompletionDocTemplateId is not int templateId) + return null; + + var template = await _dataService.GetActiveTemplateSummaryAsync(templateId, cancellationToken); + if (template == null) + throw new ServicesRegistryValidationException( + "TemplateInvalid", + "Completion document template must reference an active, non-deleted template."); + + return template; + } + + private static ServiceDto MapToDto(ServiceRegistryRow row) + { + return new ServiceDto + { + Id = row.Id, + Name = row.Name, + Trade = row.Trade, + IconKey = row.IconKey, + RequiresCompletionDocument = row.RequiresCompletionDocument, + CompletionDocTemplate = row.CompletionDocTemplateId == null + ? null + : new LinkedTemplateSummaryDto + { + Id = row.CompletionDocTemplateId.Value, + Name = row.CompletionDocTemplateName ?? "" + }, + IsActive = row.IsActive, + SupportedWorkOrderTypes = row.SupportedWorkOrderTypes.OrderBy(t => (int)t).ToList() + }; + } + } +} diff --git a/SeaHaven.Services/Interfaces/IServicesRegistryService.cs b/SeaHaven.Services/Interfaces/IServicesRegistryService.cs new file mode 100644 index 0000000..771238b --- /dev/null +++ b/SeaHaven.Services/Interfaces/IServicesRegistryService.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + // Global Services registry. Tenant scope is global (no AccountId), + // matching DropdownOptions. Reads are authenticated; create/update require + // the Scheduler or Admin role and deactivation is Admin-only, enforced at + // this service entry — never in the controller alone. + public interface IServicesRegistryService + { + Task> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken); + Task UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken); + Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs b/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs new file mode 100644 index 0000000..6dbb735 --- /dev/null +++ b/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs @@ -0,0 +1,316 @@ +using System.Reflection; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Migrations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +// Shape evidence for the global Services registry migration (table, +// indexes, FK, idempotent additive seed) plus a SQL Server execution test that +// is silently skipped when no provider is reachable, mirroring existing +// provider-gated migration tests. +public class SH303ServicesRegistryMigrationTests +{ + private const string LocalDbMaster = + @"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3"; + + [Fact] + public void Up_CreatesRegistryTablesUniqueIndexesFkAndIdempotentProblemSeed() + { + var operations = BuildOperations("Up"); + + var createTables = operations.OfType().ToList(); + Assert.Equal( + new HashSet { "Services", "ServiceWorkOrderTypes" }, + createTables.Select(t => t.Name).ToHashSet()); + + var services = createTables.Single(t => t.Name == "Services"); + Assert.Contains(services.Columns, c => c.Name == "NormalizedName" && (string)c.ColumnType! == "nvarchar(200)"); + Assert.Contains(services.Columns, c => c.Name == "CompletionDocTemplateId" && c.IsNullable); + Assert.Contains(services.Columns, c => c.Name == "RequiresCompletionDocument" && !c.IsNullable); + Assert.Contains(services.Columns, c => c.Name == "IsActive" && !c.IsNullable); + + var foreignKeys = services.ForeignKeys.Concat( + createTables.Single(t => t.Name == "ServiceWorkOrderTypes").ForeignKeys) + .ToList(); + Assert.Contains(foreignKeys, fk => + fk.Table == "Services" + && fk.PrincipalTable == "CompletionDocTemplates" + && fk.OnDelete == ReferentialAction.Restrict); + Assert.Contains(foreignKeys, fk => + fk.Table == "ServiceWorkOrderTypes" && fk.PrincipalTable == "Services"); + + var indexes = operations.OfType().ToList(); + Assert.Contains(indexes, i => + i.Table == "Services" && i.Columns.SequenceEqual(new[] { "NormalizedName" }) && i.IsUnique); + Assert.Contains(indexes, i => + i.Table == "ServiceWorkOrderTypes" + && i.Columns.SequenceEqual(new[] { "ServiceId", "WorkOrderType" }) + && i.IsUnique); + + // The seed is raw additive SQL: read-only over DropdownOptions, idempotent + // via NormalizedName NOT EXISTS, and never mutates dropdown rows. + var seedSql = string.Join("\n", operations.OfType().Select(o => o.Sql)); + Assert.Contains("[Category] = N'Problem'", seedSql); + Assert.Contains("[IsActive] = 1", seedSql); + Assert.Contains("NOT EXISTS", seedSql); + Assert.Contains("INSERT INTO [Services]", seedSql); + Assert.Contains("INSERT INTO [ServiceWorkOrderTypes]", seedSql); + Assert.DoesNotContain("UPDATE [DropdownOptions]", seedSql); + Assert.DoesNotContain("DELETE FROM [DropdownOptions]", seedSql); + Assert.DoesNotContain("DELETE [DropdownOptions]", seedSql); + } + + [Fact] + public void Down_DropsRegistryTables() + { + var operations = BuildOperations("Down"); + + var drops = operations.OfType().Select(d => d.Name).ToList(); + Assert.Equal( + new HashSet { "Services", "ServiceWorkOrderTypes" }, + drops.ToHashSet()); + Assert.Empty(operations.OfType()); + } + + [Fact] + public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable() + { + var masterConnectionString = await ResolveMasterConnectionStringAsync(); + if (masterConnectionString == null) + return; + + var dbName = $"SH303Registry_{Guid.NewGuid():N}"; + var connectionString = WithDatabase(masterConnectionString, dbName); + + try + { + await CreateDatabaseAsync(masterConnectionString, dbName); + + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + await using (var seedSource = connection.CreateCommand()) + { + seedSource.CommandText = + """ + CREATE TABLE DropdownOptions ( + Id int NOT NULL IDENTITY PRIMARY KEY, + Category nvarchar(max) NOT NULL, + Value nvarchar(max) NOT NULL, + ParentValue nvarchar(max) NULL, + SortOrder int NOT NULL, + IsActive bit NOT NULL + ); + CREATE TABLE CompletionDocTemplates ( + Id int NOT NULL IDENTITY PRIMARY KEY, + Name nvarchar(200) NOT NULL, + ServiceKey nvarchar(max) NOT NULL, + TemplateUrl nvarchar(max) NOT NULL, + IsActive bit NOT NULL, + IsDeleted bit NULL, + createdby nvarchar(max) NULL, + DeleterUserId nvarchar(max) NULL, + DeletionTime datetime2 NULL, + CreatedDate datetime2 NULL, + LastModificationTime datetime2 NULL, + LastModifierUserId int NULL + ); + INSERT INTO DropdownOptions (Category, Value, SortOrder, IsActive) VALUES + (N'Problem', N'Leak', 0, 1), + (N'Problem', N'leak', 1, 1), + (N'Problem', N'No Power', 2, 1), + (N'Problem', N'Not Cooling', 3, 1), + (N'Problem', N'Not Heating', 4, 1), + (N'Problem', N'Alarm Fault', 5, 1), + (N'Problem', N'Broken/Damaged', 6, 1), + (N'Problem', N'Retired Ghost', 7, 0), + (N'Trade', N'Electrical', 0, 1); + """; + await seedSource.ExecuteNonQueryAsync(); + } + + await using var context = new ApplicationDbContext( + new DbContextOptionsBuilder().UseSqlServer(connection).Options); + await ApplyMigrationAsync(context, new SH303_ServicesRegistry()); + + await using (var servicesCmd = connection.CreateCommand()) + { + servicesCmd.CommandText = + """ + SELECT [Name], [Trade], [IconKey] + FROM [Services] + ORDER BY [Name]; + """; + await using var reader = await servicesCmd.ExecuteReaderAsync(); + var rows = new List(); + while (await reader.ReadAsync()) + rows.Add($"{reader.GetString(0)}|{reader.GetString(1)}|{reader.GetString(2)}"); + + // Repeated case variants collapse onto one deterministic row and the + // inactive value plus the non-Problem category never seed. + var expected = new[] + { + "Alarm Fault|Fire & Life Safety|fire-life-safety", + "Broken/Damaged|General Building & Handyman|general-building-handyman", + "Leak|Plumbing & Water Systems|plumbing-water-systems", + "No Power|Electrical|electrical", + "Not Cooling|HVAC|hvac", + "Not Heating|HVAC|hvac" + }; + Assert.Equal(expected, rows.OrderBy(r => r).ToArray()); + } + + await using (var typesCmd = connection.CreateCommand()) + { + typesCmd.CommandText = + """ + SELECT COUNT(*) + FROM [ServiceWorkOrderTypes] t + JOIN [Services] s ON s.[Id] = t.[ServiceId] + WHERE t.[WorkOrderType] IN (2, 3, 6); + """; + Assert.Equal(18, Convert.ToInt32(await typesCmd.ExecuteScalarAsync())); // 6 services x 3 supported types + } + + await using (var untouchedCmd = connection.CreateCommand()) + { + untouchedCmd.CommandText = "SELECT COUNT(*) FROM [DropdownOptions];"; + Assert.Equal(9, Convert.ToInt32(await untouchedCmd.ExecuteScalarAsync())); + } + + // Re-running only the seed statements must be a no-op (idempotence). + var before = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"); + await ApplySeedStatementsAsync(context); + var after = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"); + Assert.Equal(before, after); + + var migration = new SH303_ServicesRegistry(); + await ApplyMigrationAsync(context, migration, "Down"); + Assert.Equal(0, await CountAsync(connection, "SELECT COUNT(*) FROM sys.tables WHERE name IN ('Services', 'ServiceWorkOrderTypes');")); + + await ApplyMigrationAsync(context, migration, "Up"); + Assert.Equal(6, await CountAsync(connection, "SELECT COUNT(*) FROM [Services];")); + Assert.Equal(18, await CountAsync(connection, "SELECT COUNT(*) FROM [ServiceWorkOrderTypes];")); + } + finally + { + await DropDatabaseAsync(masterConnectionString, dbName); + } + } + + private static List BuildOperations(string direction) + { + var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); + var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Migration.{direction} not found."); + operation.Invoke(new SH303_ServicesRegistry(), [builder]); + return builder.Operations; + } + + private static async Task ApplyMigrationAsync(ApplicationDbContext context, Migration migration, string direction = "Up") + { + var builder = new MigrationBuilder(context.Database.ProviderName!); + var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Migration.{direction} not found."); + operation.Invoke(migration, [builder]); + + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(builder.Operations, context.Model); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task ApplySeedStatementsAsync(ApplicationDbContext context) + { + var sqlOperations = BuildOperations("Up").OfType().ToList(); + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(sqlOperations, context.Model); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task CountAsync(SqlConnection connection, string commandText) + { + await using var command = connection.CreateCommand(); + command.CommandText = commandText; + return Convert.ToInt32(await command.ExecuteScalarAsync()); + } + + private static async Task ResolveMasterConnectionStringAsync() + { + var configured = Environment.GetEnvironmentVariable("SH303_SQLSERVER_MASTER"); + var candidate = !string.IsNullOrWhiteSpace(configured) + ? WithDatabase(configured, "master") + : OperatingSystem.IsWindows() ? LocalDbMaster : null; + + if (candidate == null) + return null; + + try + { + await using var connection = new SqlConnection(candidate); + await connection.OpenAsync(); + return candidate; + } + catch (SqlException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private static async Task CreateDatabaseAsync(string masterConnectionString, string dbName) + { + await using var connection = new SqlConnection(masterConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"CREATE DATABASE {QuoteSqlServerIdentifier(dbName)};"; + await command.ExecuteNonQueryAsync(); + } + + private static async Task DropDatabaseAsync(string masterConnectionString, string dbName) + { + try + { + var quoted = QuoteSqlServerIdentifier(dbName); + await using var connection = new SqlConnection(masterConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = + $""" + IF DB_ID(@dbName) IS NOT NULL + BEGIN + ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE {quoted}; + END + """; + command.Parameters.AddWithValue("@dbName", dbName); + await command.ExecuteNonQueryAsync(); + } + catch + { + // Best-effort cleanup for ephemeral SQL Server databases. + } + } + + private static string QuoteSqlServerIdentifier(string name) => + "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; + + private static string WithDatabase(string connectionString, string database) + { + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = database + }; + return builder.ConnectionString; + } +} From dec8b15278b73b7379568fd44c605139b3626831 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:31:21 -0300 Subject: [PATCH 02/22] fix(services): translate duplicate name races --- .../ServicesRegistryDataService.cs | 29 +++++++++++++++++-- .../Models/ServicesRegistryModels.cs | 14 +++++++++ .../Implementation/ServicesRegistryService.cs | 22 ++++++++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 SeaHaven.DataServices/Models/ServicesRegistryModels.cs diff --git a/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs b/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs index b6acd06..c2e9c77 100644 --- a/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs +++ b/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -90,7 +91,15 @@ namespace SeaHaven.DataServices.Implementation public async Task AddServiceAsync(Service service, CancellationToken cancellationToken) { await _context.Services.AddAsync(service, cancellationToken); - await _context.SaveChangesAsync(cancellationToken); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (IsUniqueNameViolation(ex)) + { + throw new ServicesRegistryDuplicateNameException(ex); + } + return service; } @@ -112,7 +121,14 @@ namespace SeaHaven.DataServices.Implementation }); } - await _context.SaveChangesAsync(cancellationToken); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (IsUniqueNameViolation(ex)) + { + throw new ServicesRegistryDuplicateNameException(ex); + } } public async Task DeactivateServiceAsync(int id, CancellationToken cancellationToken) @@ -126,5 +142,14 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(cancellationToken); return true; } + + private static bool IsUniqueNameViolation(DbUpdateException exception) + { + var message = exception.InnerException?.Message ?? exception.Message; + return message.Contains("IX_Services_NormalizedName", StringComparison.OrdinalIgnoreCase) + || message.Contains("NormalizedName", StringComparison.OrdinalIgnoreCase) + && (message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) + || message.Contains("unique", StringComparison.OrdinalIgnoreCase)); + } } } diff --git a/SeaHaven.DataServices/Models/ServicesRegistryModels.cs b/SeaHaven.DataServices/Models/ServicesRegistryModels.cs new file mode 100644 index 0000000..b34b25b --- /dev/null +++ b/SeaHaven.DataServices/Models/ServicesRegistryModels.cs @@ -0,0 +1,14 @@ +namespace SeaHaven.DataServices.Models +{ + /// + /// Raised by the registry data service when the database unique name index + /// wins a concurrent create or update race. + /// + public sealed class ServicesRegistryDuplicateNameException : Exception + { + public ServicesRegistryDuplicateNameException(Exception innerException) + : base("A service name is already in use.", innerException) + { + } + } +} diff --git a/SeaHaven.Services/Implementation/ServicesRegistryService.cs b/SeaHaven.Services/Implementation/ServicesRegistryService.cs index 79e2dd9..b6c7dd0 100644 --- a/SeaHaven.Services/Implementation/ServicesRegistryService.cs +++ b/SeaHaven.Services/Implementation/ServicesRegistryService.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; @@ -71,7 +72,14 @@ namespace SeaHaven.Services.Implementation .ToList() }; - await _dataService.AddServiceAsync(service, cancellationToken); + try + { + await _dataService.AddServiceAsync(service, cancellationToken); + } + catch (ServicesRegistryDuplicateNameException) + { + throw DuplicateName(); + } var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken); return MapToDto(row!); @@ -101,7 +109,14 @@ namespace SeaHaven.Services.Implementation service.RequiresCompletionDocument = input.RequiresCompletionDocument; service.CompletionDocTemplateId = template?.Id; - await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken); + try + { + await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken); + } + catch (ServicesRegistryDuplicateNameException) + { + throw DuplicateName(); + } var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken); return MapToDto(row!); @@ -123,6 +138,9 @@ namespace SeaHaven.Services.Implementation throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."); } + private static ServicesRegistryValidationException DuplicateName() => + new("DuplicateName", "A service with this name already exists."); + private static string RequireValidName(string? name) { if (string.IsNullOrWhiteSpace(name)) From 33f517620bc8dea84fac9aa9a1e0db4be50a60b3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:02:57 -0300 Subject: [PATCH 03/22] feat(work-orders): link service selections to registry --- .../Auth/ApplicationDbContext.cs | 6 + ...606_SH187_WorkOrderServiceLink.Designer.cs | 4134 +++++++++++++++++ ...260916204606_SH187_WorkOrderServiceLink.cs | 73 + .../ApplicationDbContextModelSnapshot.cs | 16 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 5 + .../Helpers/WorkOrderBoardProjection.cs | 4 + .../Interfaces/WorkOrderBoardModels.cs | 2 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 1 + .../Helpers/WorkOrderBoardFieldNames.cs | 3 + .../WorkOrderBoardCreateService.cs | 52 +- .../Implementation/WorkOrderBoardService.cs | 4 +- .../WorkOrderBoardUpdateService.cs | 90 +- .../SH303ServicesRegistryMigrationTests.cs | 28 +- .../WorkOrderBoardServiceTests.cs | 2 + 15 files changed, 4417 insertions(+), 5 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 5dab440..554f826 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries .HasIndex(w => w.InternalWONumber) .HasFilter("[istemplate] = 0"); + builder.Entity() + .HasOne(w => w.ServiceDefinition) + .WithMany() + .HasForeignKey(w => w.ServiceId) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) .HasFilter("[istemplate] = 0"); diff --git a/Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs new file mode 100644 index 0000000..9a5cd12 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs @@ -0,0 +1,4134 @@ +// +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("20260916204606_SH187_WorkOrderServiceLink")] + partial class SH187_WorkOrderServiceLink + { + /// + 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.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", 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("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + 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("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + 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("ServiceId"); + + 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.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + 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.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + 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.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + 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/20260916204606_SH187_WorkOrderServiceLink.cs b/Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.cs new file mode 100644 index 0000000..6eefdf6 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH187_WorkOrderServiceLink : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ServiceId", + table: "workOrders", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "ServiceNameSnapshot", + table: "workOrders", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.Sql( + """ + UPDATE w + SET + w.[ServiceNameSnapshot] = LEFT(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')), 200), + w.[ServiceId] = s.[Id] + FROM [workOrders] w + LEFT JOIN [Services] s + ON (s.[IsDeleted] <> 1 OR s.[IsDeleted] IS NULL) + AND s.[NormalizedName] = LOWER(LTRIM(RTRIM(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N''))))) + WHERE w.[ServiceNameSnapshot] IS NULL; + """); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_ServiceId", + table: "workOrders", + column: "ServiceId"); + + migrationBuilder.AddForeignKey( + name: "FK_workOrders_Services_ServiceId", + table: "workOrders", + column: "ServiceId", + principalTable: "Services", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_workOrders_Services_ServiceId", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_ServiceId", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "ServiceId", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "ServiceNameSnapshot", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 730bc42..268e033 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2741,6 +2741,13 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.Property("ServiceNotes") .HasMaxLength(4000) .HasColumnType("nvarchar(4000)"); @@ -2825,6 +2832,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ScheduledDate"); + b.HasIndex("ServiceId"); + b.HasIndex("SiteCode") .HasFilter("[istemplate] = 0"); @@ -3843,6 +3852,11 @@ namespace Data.SeaHavenIndustries.Migrations .HasForeignKey("PrimaryDispatchId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("Account"); b.Navigation("AssignToUser"); @@ -3850,6 +3864,8 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Locations"); b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 3f71f87..8e5cd54 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -43,6 +43,11 @@ namespace Data.SeaHavenIndustries /// SHOC board "Service" column (PM type label). Not Trade/Problem. [MaxLength(128)] public string? Service { get; set; } + public int? ServiceId { get; set; } + [MaxLength(200)] + public string? ServiceNameSnapshot { get; set; } + [ForeignKey(nameof(ServiceId))] + public virtual Service? ServiceDefinition { get; set; } public int? PrimaryDispatchId { get; set; } [ForeignKey(nameof(PrimaryDispatchId))] public virtual Dispatch? PrimaryDispatch { get; set; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 04beecf..db054de 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -34,6 +34,8 @@ namespace SeaHaven.DataServices.Helpers w.OriginalDate, w.OriginalWeek, w.WorkOrderType, + w.ServiceId, + w.ServiceNameSnapshot, w.SiteCode, LocationName = w.Locations != null ? w.Locations.Name : null, WoPocName = w.PocName, @@ -136,6 +138,8 @@ namespace SeaHaven.DataServices.Helpers w.CarriedOver, w.IsAddOn, w.WorkOrderType, + w.ServiceId, + w.ServiceNameSnapshot, frozenSite?.Code ?? w.SiteCode, frozenSite?.Label ?? w.LocationName, pocName, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index bd19e54..03cbdae 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Interfaces int CarriedOver, bool IsAddOn, WorkOrderType? WorkOrderType, + int? ServiceId, + string? ServiceNameSnapshot, string? SiteCode, string? LocationName, string? PocName, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 2a408f7..4070b4b 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -84,6 +84,8 @@ namespace SeaHaven.Services.DTOs public DateOnly? OriginalDate { get; set; } public DateOnly? OriginalWeek { get; set; } public WorkOrderType? WorkOrderType { get; set; } + public int? ServiceId { get; set; } + public string? ServiceNameSnapshot { get; set; } public bool IsPastDue { get; set; } public string? SiteCode { get; set; } public string? LocationName { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 8c04373..aaac7d1 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -30,6 +30,7 @@ namespace SeaHaven.Services.DTOs public string? Trade { get; set; } /// Primary service label; persisted as Trade / board pm. public string? PrimaryService { get; set; } + public int? ServiceId { get; set; } public List? ExtraServices { get; set; } public string? ServiceNotes { get; set; } /// Per-WO Aveta compliance. Omit or false = not required. diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index 52eb5b5..985242c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -17,6 +17,7 @@ namespace SeaHaven.Services.Helpers public const string DocStatus = "docStatus"; public const string CompletedDate = "completedDate"; public const string Pm = "pm"; + public const string ServiceId = "serviceId"; public const string ServiceNotes = "serviceNotes"; public const string ExtraServices = "extraServices"; public const string AdditionalContacts = "additionalContacts"; @@ -49,6 +50,7 @@ namespace SeaHaven.Services.Helpers DocStatus, CompletedDate, Pm, + ServiceId, ServiceNotes, ExtraServices, AdditionalContacts, @@ -93,6 +95,7 @@ namespace SeaHaven.Services.Helpers DocStatus => "DocStatus", CompletedDate => "CompletedDate", Pm => "Trade", + ServiceId => "ServiceId", ServiceNotes => "ServiceNotes", ExtraServices => "ExtraServices", AdditionalContacts => "AdditionalContacts", diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 0305e1e..a0d68d0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderBoardCreateValidation _validator; private readonly IWorkOrderAccountResolver _accountResolver; + private readonly IServicesRegistryService? _servicesRegistryService; public WorkOrderBoardCreateService( IWorkOrderBoardDataService boardDataService, @@ -27,7 +28,8 @@ namespace SeaHaven.Services.Implementation IWorkOrderBoardService boardService, IWorkOrderAuditService auditService, IWorkOrderBoardCreateValidation validator, - IWorkOrderAccountResolver accountResolver) + IWorkOrderAccountResolver accountResolver, + IServicesRegistryService? servicesRegistryService = null) { _boardDataService = boardDataService; _mutationData = mutationData; @@ -35,6 +37,7 @@ namespace SeaHaven.Services.Implementation _auditService = auditService; _validator = validator; _accountResolver = accountResolver; + _servicesRegistryService = servicesRegistryService; } public async Task CreateAsync( @@ -54,7 +57,8 @@ namespace SeaHaven.Services.Implementation var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken); var siteCode = request.SiteCode!.Trim(); - var primaryService = ResolvePrimaryService(request); + var selectedService = await ResolveServiceAsync(request.ServiceId, request.WorkOrderType, request.PrimaryService ?? request.Trade, cancellationToken); + var primaryService = selectedService?.Name ?? ResolvePrimaryService(request); var extraServicesJson = SerializeExtraServices(request.ExtraServices); var serviceNotes = TrimOrNull(request.ServiceNotes); var pocName = TrimOrNull(request.PocName); @@ -87,6 +91,8 @@ namespace SeaHaven.Services.Implementation AccountId = accountId, Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Trade = primaryService, + ServiceId = selectedService?.Id, + ServiceNameSnapshot = selectedService?.Name, ExtraServices = extraServicesJson, ServiceNotes = serviceNotes, AvetaRequired = request.AvetaRequired == true, @@ -138,6 +144,8 @@ namespace SeaHaven.Services.Implementation changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description)); if (workOrder.Trade != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade)); + if (workOrder.ServiceId.HasValue) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceId", "", workOrder.ServiceId.Value.ToString())); if (workOrder.ExtraServices != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices)); if (workOrder.ServiceNotes != null) @@ -251,6 +259,46 @@ namespace SeaHaven.Services.Implementation return null; } + private async Task ResolveServiceAsync( + int? serviceId, + WorkOrderType workOrderType, + string? legacyLabel, + CancellationToken cancellationToken) + { + if (_servicesRegistryService == null) + { + if (serviceId.HasValue) + throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId is not available."); + return null; + } + + if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType)) + { + if (serviceId.HasValue) + throw new WorkOrderBoardValidationException( + "ServiceInvalid", + "serviceId is not supported for this work order type."); + return null; + } + + var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken); + if (serviceId.HasValue) + { + var selected = services.FirstOrDefault(service => service.Id == serviceId.Value); + if (selected == null) + throw new WorkOrderBoardValidationException( + "ServiceInvalid", + "serviceId must reference an active service that supports this work order type."); + return selected; + } + + if (string.IsNullOrWhiteSpace(legacyLabel)) + return null; + + return services.FirstOrDefault(service => + string.Equals(service.Name.Trim(), legacyLabel.Trim(), StringComparison.OrdinalIgnoreCase)); + } + private static string? SerializeExtraServices(List? extras) { if (extras == null || extras.Count == 0) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 1f19a3b..085e3db 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -108,6 +108,8 @@ namespace SeaHaven.Services.Implementation OriginalDate = row.OriginalDate, OriginalWeek = row.OriginalWeek, WorkOrderType = row.WorkOrderType, + ServiceId = row.ServiceId, + ServiceNameSnapshot = row.ServiceNameSnapshot, IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow), SiteCode = row.SiteCode, LocationName = row.LocationName, @@ -136,7 +138,7 @@ namespace SeaHaven.Services.Implementation TechPhone = row.TechPhone, ApptDate = row.DispatchApptDate ?? row.ScheduledDate, ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd), - Pm = row.Trade ?? row.Problem, + Pm = row.ServiceNameSnapshot ?? row.Trade ?? row.Problem, ServiceNotes = row.ServiceNotes, ExtraServices = ParseExtraServices(row.ExtraServices), AdditionalContacts = MapAdditionalContacts(row), diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index b415035..5a04011 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -16,15 +16,18 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardDataService _boardDataService; private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderAuditService _auditService; + private readonly IServicesRegistryService? _servicesRegistryService; public WorkOrderBoardUpdateService( IWorkOrderBoardDataService boardDataService, IWorkOrderBoardMutationDataService mutationData, - IWorkOrderAuditService auditService) + IWorkOrderAuditService auditService, + IServicesRegistryService? servicesRegistryService = null) { _boardDataService = boardDataService; _mutationData = mutationData; _auditService = auditService; + _servicesRegistryService = servicesRegistryService; } public async Task PatchFieldAsync( @@ -73,6 +76,13 @@ namespace SeaHaven.Services.Implementation _mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); + if (canonicalField.Equals(WorkOrderBoardFieldNames.WorkOrderType, StringComparison.OrdinalIgnoreCase) + && workOrder.ServiceId.HasValue) + { + var nextType = ParseWorkOrderType(value: request.Value); + await EnsureServiceSupportsTypeAsync(workOrder.ServiceId.Value, nextType, ct); + } + Dispatch? dispatch = null; ResolvedDispatch? resolved = null; if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField)) @@ -181,6 +191,7 @@ namespace SeaHaven.Services.Implementation { WorkOrderBoardFieldNames.WoNumber => new List { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) }, WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField), + WorkOrderBoardFieldNames.ServiceId => await ApplyServiceIdAsync(workOrder, value, auditField, cancellationToken), WorkOrderBoardFieldNames.Severity => new List { ApplySeverity(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField), @@ -388,6 +399,83 @@ namespace SeaHaven.Services.Implementation return changes; } + private async Task> ApplyServiceIdAsync( + WorkOrder workOrder, + string? value, + string auditField, + CancellationToken cancellationToken) + { + if (_servicesRegistryService == null) + throw new WorkOrderBoardValidationException("ServiceInvalid", "Service registry is unavailable."); + + if (string.IsNullOrWhiteSpace(value)) + { + var previousServiceId = workOrder.ServiceId; + var previousServiceLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade; + workOrder.ServiceId = null; + workOrder.ServiceNameSnapshot = null; + workOrder.Trade = null; + return new List + { + FieldChange.ForField(auditField, previousServiceId?.ToString(), null), + FieldChange.ForField("Trade", previousServiceLabel, null) + }; + } + + if (!int.TryParse(value.Trim(), out var serviceId) || serviceId <= 0) + throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer."); + + var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken); + if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM) + || service == null + || !service.IsActive + || !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)) + { + throw new WorkOrderBoardValidationException( + "ServiceInvalid", + "serviceId must reference an active service that supports this work order type."); + } + + var oldId = workOrder.ServiceId; + var oldLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade; + workOrder.ServiceId = service.Id; + workOrder.ServiceNameSnapshot = service.Name; + workOrder.Trade = service.Name; + return new List + { + oldId == service.Id + ? FieldChange.Unchanged(auditField) + : FieldChange.ForField(auditField, oldId?.ToString(), service.Id.ToString()), + oldLabel == service.Name + ? FieldChange.Unchanged("Trade") + : FieldChange.ForField("Trade", oldLabel, service.Name) + }; + } + + private async Task EnsureServiceSupportsTypeAsync(int serviceId, WorkOrderType workOrderType, CancellationToken cancellationToken) + { + if (_servicesRegistryService == null) + throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable."); + + var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken); + if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType)) + throw new WorkOrderBoardValidationException( + "ServiceTypeInvalid", + "The selected service does not support this work order type."); + } + + private static WorkOrderType ParseWorkOrderType(string? value) + { + if (string.IsNullOrWhiteSpace(value) + || string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase) + || !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed)) + { + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); + } + + return parsed; + } + private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField) { if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized)) diff --git a/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs b/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs index 6dbb735..831e42f 100644 --- a/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs +++ b/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs @@ -78,6 +78,28 @@ public class SH303ServicesRegistryMigrationTests Assert.Empty(operations.OfType()); } + [Fact] + public void SH187_Up_AddsNullableServiceLinkAndBackfillsLegacyLabels() + { + var operations = BuildOperations("Up"); + + var columns = operations.OfType().ToList(); + Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceId" && c.IsNullable); + Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceNameSnapshot" + && c.IsNullable && c.MaxLength == 200); + Assert.Contains(operations.OfType(), i => + i.Table == "workOrders" && i.Columns.SequenceEqual(new[] { "ServiceId" })); + Assert.Contains(operations.OfType(), fk => + fk.Table == "workOrders" + && fk.PrincipalTable == "Services" + && fk.OnDelete == ReferentialAction.Restrict); + + var sql = string.Join("\n", operations.OfType().Select(o => o.Sql)); + Assert.Contains("UPDATE w", sql); + Assert.Contains("ServiceNameSnapshot", sql); + Assert.Contains("LEFT JOIN [Services]", sql); + } + [Fact] public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable() { @@ -205,11 +227,15 @@ public class SH303ServicesRegistryMigrationTests } private static List BuildOperations(string direction) + => BuildOperations(direction); + + private static List BuildOperations(string direction) + where TMigration : Migration, new() { var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Migration.{direction} not found."); - operation.Invoke(new SH303_ServicesRegistry(), [builder]); + operation.Invoke(new TMigration(), [builder]); return builder.Operations; } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index d1d9343..9e35213 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -1055,6 +1055,8 @@ public class WorkOrderBoardServiceTests CarriedOver: 0, IsAddOn: false, WorkOrderType: null, + ServiceId: null, + ServiceNameSnapshot: null, SiteCode: null, LocationName: null, PocName: null, From 7947e2e77defb6807bcce82f4769cb9e29192c9a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 19:19:10 -0300 Subject: [PATCH 04/22] feat(dashboard): expose KPI counts (SH-353) --- .../Implementation/DashboardDataService.cs | 64 +++++++++++++++++++ .../Interfaces/IDashboardDataService.cs | 11 ++++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 3 + .../Implementation/DashboardService.cs | 8 +++ 4 files changed, 86 insertions(+) diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index a56f74f..91c545d 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -88,6 +88,70 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } + public async Task GetKpiCountsAsync( + int? accountId, + string? dispatcherId, + DateOnly today, + CancellationToken cancellationToken) + { + var tomorrow = today.AddDays(1); + var dayAfterTomorrow = tomorrow.AddDays(1); + var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); + if (dispatcherId != null) + workOrders = workOrders.Where(w => w.AssignTo == dispatcherId); + + var scheduledTomorrow = await workOrders + .Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue) + && w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)) + .CountAsync(cancellationToken); + + var avetaPending = await workOrders + .Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue) + && w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue) + && w.AvetaRequired + && w.LifecycleStatus != LifecycleStatus.Completed + && w.LifecycleStatus != LifecycleStatus.Canceled + && (w.LifecycleStatus != null + || ((w.LegacyStatus ?? w.Status) != "Done" + && (w.LegacyStatus ?? w.Status) != "Completed" + && (w.LegacyStatus ?? w.Status) != "Complete" + && (w.LegacyStatus ?? w.Status) != "Closed" + && (w.LegacyStatus ?? w.Status) != "Canceled" + && (w.LegacyStatus ?? w.Status) != "Cancelled")) + && !w.workOrderAttachments!.Any(attachment => + (attachment.IsDeleted == null || attachment.IsDeleted == false) + && attachment.Category == WorkOrderMediaCategory.Aveta)) + .CountAsync(cancellationToken); + + var pendingUplifts = _context.DispatchUpliftRequests + .AsNoTracking() + .Where(request => request.Status == "Pending" + && (request.IsDeleted == null || request.IsDeleted == false) + && request.Dispatch != null + && (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false)); + if (accountId is int account) + { + pendingUplifts = pendingUplifts.Where(request => + (request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account) + || request.Dispatch.DispatchWorkOrders!.Any(link => + link.WorkOrder != null && link.WorkOrder.AccountId == account)); + } + if (dispatcherId != null) + { + pendingUplifts = pendingUplifts.Where(request => + (request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId) + || request.Dispatch.DispatchWorkOrders!.Any(link => + link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId)); + } + + return new DashboardKpiCounts( + scheduledTomorrow, + await pendingUplifts.CountAsync(cancellationToken), + avetaPending); + } + public async Task> GetDispatcherWorkloadAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 5348703..51ef7b5 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -8,6 +8,12 @@ namespace SeaHaven.DataServices.Interfaces int? accountId, CancellationToken cancellationToken); + Task GetKpiCountsAsync( + int? accountId, + string? dispatcherId, + DateOnly today, + CancellationToken cancellationToken); + Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, @@ -53,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces public int NotDispatched { get; set; } public int Completed { get; set; } } + + public sealed record DashboardKpiCounts( + int ScheduledTomorrow, + int PendingUplifts, + int AvetaPending); } diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index ef5c70d..3544ebb 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -100,6 +100,9 @@ namespace SeaHaven.Services.DTOs public int Open { get; set; } public int NotDispatched { get; set; } public int Completed { get; set; } + public int ScheduledTomorrow { get; set; } + public int PendingUplifts { get; set; } + public int AvetaPending { get; set; } public DashboardBreakdownDTO Breakdown { get; set; } = new(); public int DueCount { get; set; } public int CompletedDueCount { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index a4bdfce..3193c08 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -35,6 +35,11 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); + var kpis = await _dataService.GetKpiCountsAsync( + accountId, + dispatcherId, + DashboardBusinessTime.Today(), + cancellationToken); var workOrders = await _dataService.GetDashboardWorkOrdersAsync( accountId, dispatcherId, @@ -51,6 +56,9 @@ namespace SeaHaven.Services.Implementation Open = useMetrics ? metrics.Open : counts.Open, NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched, Completed = useMetrics ? metrics.Completed : counts.Completed, + ScheduledTomorrow = kpis.ScheduledTomorrow, + PendingUplifts = kpis.PendingUplifts, + AvetaPending = kpis.AvetaPending, Breakdown = metrics.Breakdown, DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, From 3cb1e3e3f3593e29351dd78420d9fbe34a2b97af Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:03:35 -0300 Subject: [PATCH 05/22] feat(uplifts): add approval queue read contract (SH-207) --- .../UpliftControllerTests.cs | 50 ++ .../UpliftQueueReadTests.cs | 465 ++++++++++++++++++ .../Models/VendorPortalReadModels.cs | 17 + .../Implementation/UpliftDataService.cs | 97 +++- .../Interfaces/IUpliftDataService.cs | 6 + SeaHaven.Services/DTOs/UpliftDTOs.cs | 11 + .../Implementation/UpliftService.cs | 37 ++ .../WorkOrderBoardCancelServiceTests.cs | 1 + 8 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index abad676..dc634aa 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -52,6 +52,56 @@ public class UpliftControllerTests envelope.Status.Should().Be("Success"); } + [Fact] + public async Task List_PassesStatusAndTierFiltersToService() + { + var service = new Mock(); + service.Setup(x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny())) + .ReturnsAsync(new UpliftListResultDTO()); + + var controller = NewController(service); + + await controller.List("Approved", 2, 3, 50); + + service.Verify( + x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task List_ReturnsQueueContractFieldsInEnvelope() + { + var service = new Mock(); + var item = new UpliftListItemDTO + { + Id = 7, + WorkOrderId = 11, + WorkOrderNumber = "WO-77", + WorkOrderSite = "SITE-EAST", + WorkOrderService = "HVAC", + RequestedByName = "Gateway", + WorkOrderAutoApprovedTotal = 150m, + WorkOrderAdminApprovedTotal = 300m, + WorkOrderApprovedExposureTotal = 450m + }; + service.Setup(x => x.ListAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpliftListResultDTO { Total = 1, Page = 1, PageSize = 25, Items = new[] { item } }); + + var controller = NewController(service); + + var result = await controller.List(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var data = envelope.Data as UpliftListResultDTO; + var returned = data!.Items.Should().ContainSingle().Subject; + returned.WorkOrderNumber.Should().Be("WO-77"); + returned.WorkOrderSite.Should().Be("SITE-EAST"); + returned.WorkOrderService.Should().Be("HVAC"); + returned.RequestedByName.Should().Be("Gateway"); + returned.WorkOrderApprovedExposureTotal.Should().Be(450m); + } + [Fact] public async Task Approve_NotFound_Returns404() { diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs new file mode 100644 index 0000000..ad539bd --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -0,0 +1,465 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Behavior tests for the approval queue read contract. These exercise the real +// service + data-service layers against an in-memory DbContext so that queue +// ordering, filters, flattened work-order fields, exposure aggregation, and +// per-tier read authorization are validated through the public contract. +public sealed class UpliftQueueReadTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ApprovalsOptions NewOptions() => new() + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + Tier1NotificationRecipients = new[] { "tier1@example.com" }, + Tier2NotificationRecipients = new[] { "tier2@example.com" }, + EscalationRecipients = new[] { "escalate@example.com" } + }; + + private static UpliftService NewService(ApplicationDbContext context) => + new(new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(NewOptions())); + + private static ClaimsPrincipal UserWithRoles(params string[] roles) + { + var claims = new List { new(ClaimTypes.NameIdentifier, "user-42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + + private static DispatchUpliftRequest Request( + Dispatch dispatch, + string status, + DateTime created, + DateTime? decided = null, + int tier = 1, + decimal requested = 100m, + string? reason = null, + string? requestedBy = null, + string? createdBy = null, + decimal? currentNte = null) => new() + { + DispatchId = dispatch.Id, + Status = status, + CreatedDate = created, + DecidedAt = decided, + RequiredTier = tier, + RequestedNTE = requested, + CurrentNTE = currentNte, + VendorReason = reason, + RequestedByVendorName = requestedBy, + createdby = createdBy, + NotificationStatus = "Pending" + }; + + private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync( + ApplicationDbContext context, + string number, + string site, + string service, + string vendorName = "Gateway") + { + var vendor = new Vendor { CompanyName = vendorName, IsActive = true }; + var workOrder = new WorkOrder + { + WorkerOrderNumber = number, + SiteCode = site, + Service = service, + WorkerOrderTitle = "Repair" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + return (vendor, workOrder); + } + + private static async Task SeedDispatchAsync( + ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number) + { + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + DispatchNumber = number, + Status = "Completed", + NTEAmount = 1000m + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + return dispatch; + } + + // --- Ordering --- + + [Fact] + public async Task List_PendingStatus_OrdersOldestRequestFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 3)), + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.RequestedAt).Should().Equal( + new DateTime(2026, 3, 1), + new DateTime(2026, 3, 2), + new DateTime(2026, 3, 3)); + } + + [Fact] + public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), + Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 20), + new DateTime(2026, 3, 15), + new DateTime(2026, 3, 10)); + } + + [Fact] + public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.RequestedAt).Should().ContainInOrder( + new DateTime(2026, 3, 10), + new DateTime(2026, 3, 1)); + } + + // --- Filters and scoping --- + + [Fact] + public async Task List_StatusAndTierFilters_Combine() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending"); + } + + [Fact] + public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + var deletedDispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + DispatchNumber = "DIS-DELETED", + Status = "Completed", + IsDeleted = true + }; + context.Dispatches.Add(deletedDispatch); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 2), + IsDeleted = true, + RequiredTier = 1, + RequestedNTE = 100m + }, + Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1)); + } + + // --- Flattened queue fields --- + + [Fact] + public async Task List_MapsFlattenedWorkOrderAndRequesterFields() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + dispatch, "Pending", new DateTime(2026, 3, 2), + tier: 2, requested: 400m, reason: "Scope grew", + requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderId.Should().Be(workOrder.Id); + item.WorkOrderNumber.Should().Be("WO-77"); + item.WorkOrderSite.Should().Be("SITE-EAST"); + item.WorkOrderService.Should().Be("HVAC"); + item.VendorCompanyName.Should().Be("Gateway"); + item.RequestedByName.Should().Be("Gateway"); + item.RequestedAt.Should().Be(new DateTime(2026, 3, 2)); + item.VendorReason.Should().Be("Scope grew"); + item.Delta.Should().Be(300m); + item.Status.Should().Be("Pending"); + } + + [Fact] + public async Task List_RequesterLabelFallsBackToCreatingUserName() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + dispatch, "Pending", new DateTime(2026, 3, 2), + requestedBy: null, createdBy: "user-7")); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Single().RequestedByName.Should().Be("Ada Smith"); + } + + [Fact] + public async Task List_MapsEvidenceAttachmentSummary() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + var evidence = new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "quote.pdf", + StoredFileName = "evidence.bin", + ContentType = "application/pdf", + SizeBytes = 512, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 1 + }; + context.VendorCompletionDocuments.Add(evidence); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 200m, + EvidenceDocumentId = evidence.Id + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Single(); + item.EvidenceDocumentId.Should().Be(evidence.Id); + item.EvidenceFileName.Should().Be("quote.pdf"); + item.EvidenceContentType.Should().Be("application/pdf"); + item.EvidenceSizeBytes.Should().Be(512); + } + + // --- Approved-on-WO exposure totals --- + + [Fact] + public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m), + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "NoApprovalRequired", + CreatedDate = new DateTime(2026, 3, 1), + RequiredTier = 0, + RequestedNTE = 150m + }, + Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m), + Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m), + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Withdrawn", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 999m + }, + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Revoked", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 999m + }, + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Expired", + CreatedDate = new DateTime(2026, 3, 1), + RequiredTier = 1, + RequestedNTE = 999m + }, + Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A"); + itemA.WorkOrderAutoApprovedTotal.Should().Be(150m); + itemA.WorkOrderAdminApprovedTotal.Should().Be(300m); + itemA.WorkOrderApprovedExposureTotal.Should().Be(450m); + + var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B"); + itemB.WorkOrderAutoApprovedTotal.Should().Be(0m); + itemB.WorkOrderAdminApprovedTotal.Should().Be(75m); + itemB.WorkOrderApprovedExposureTotal.Should().Be(75m); + } + + [Fact] + public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var linkedDispatch = new Dispatch + { + VendorId = vendorA.Id, + DispatchNumber = "DIS-LINKED", + Status = "Completed" + }; + context.Dispatches.Add(linkedDispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = linkedDispatch.Id, + WorkOrderId = workOrderA.Id + }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m)); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrderA.Id }, CancellationToken.None); + + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject; + total.AdminApprovedTotal.Should().Be(60m); + total.AutoApprovedTotal.Should().Be(0m); + } + + // --- Read authorization (tier roles) --- + + [Fact] + public async Task List_CanDecide_ReflectsTierRolesForTheCaller() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); + + tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue(); + tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse(); + tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue(); + tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse(); + } +} diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 28371b9..05ea0ab 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -81,6 +81,13 @@ namespace Data.SeaHavenIndustries public string? PONumber { get; set; } public int? WorkOrderId { get; set; } public string? VendorCompanyName { get; set; } + // Approval queue read contract: flattened work-order context. + public string? WorkOrderNumber { get; set; } + public string? WorkOrderSiteCode { get; set; } + public string? WorkOrderService { get; set; } + public string? RequestedByVendorName { get; set; } + public string? RequestedByFirstName { get; set; } + public string? RequestedByLastName { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } public string? VendorReason { get; set; } @@ -130,6 +137,16 @@ namespace Data.SeaHavenIndustries public bool EvidenceScanPassed { get; set; } } + // Approval queue read contract: per-work-order exposure totals. Approved exposure is + // the sum of uplift amounts that were auto-approved or admin-approved on the work + // order; pending, rejected, cancelled/withdrawn, and revoked requests are excluded. + public class WorkOrderUpliftExposureData + { + public int WorkOrderId { get; set; } + public decimal AutoApprovedTotal { get; set; } + public decimal AdminApprovedTotal { get; set; } + } + // SH-101: internal evidence-download projection. Server-side join enforces that the // returned document is the one linked to this specific uplift request and dispatch. public class UpliftEvidenceDownloadData diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index fa8d76a..8d7ac66 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -24,8 +24,13 @@ namespace SeaHaven.DataServices.Implementation from v in vendors.DefaultIfEmpty() join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences from ev in evidences.DefaultIfEmpty() + join wo in _context.workOrders on d.WorkOrderId equals wo.Id into workOrders + from wo in workOrders.DefaultIfEmpty() + join reqUser in _context.Users on u.createdby equals reqUser.Id into requestUsers + from reqUser in requestUsers.DefaultIfEmpty() where (u.IsDeleted == null || u.IsDeleted == false) - select new { u, d, v, ev }; + && (d.IsDeleted == null || d.IsDeleted == false) + select new { u, d, v, ev, wo, reqUser }; if (!string.IsNullOrWhiteSpace(status)) query = query.Where(x => x.u.Status == status); @@ -34,8 +39,18 @@ namespace SeaHaven.DataServices.Implementation var total = await query.CountAsync(cancellationToken); + // Approval queue read contract: the actionable queue (Pending) surfaces the + // oldest request first; the decision log (Approved) surfaces the most + // recently decided first. Every other read keeps the historical + // newest-request-first order. Id is the deterministic tiebreaker. + if (string.Equals(status, "Pending", StringComparison.Ordinal)) + query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id); + else if (string.Equals(status, "Approved", StringComparison.Ordinal)) + query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id); + else + query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id); + var items = await query - .OrderByDescending(x => x.u.CreatedDate) .Skip((Math.Max(page, 1) - 1) * pageSize) .Take(pageSize) .Select(x => new UpliftListItemData @@ -46,6 +61,12 @@ namespace SeaHaven.DataServices.Implementation PONumber = x.d.PONumber, WorkOrderId = x.d.WorkOrderId, VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, + WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null, + WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null, + WorkOrderService = x.wo != null ? x.wo.Service : null, + RequestedByVendorName = x.u.RequestedByVendorName, + RequestedByFirstName = x.reqUser != null ? x.reqUser.FirstName : null, + RequestedByLastName = x.reqUser != null ? x.reqUser.LastName : null, CurrentNTE = x.u.CurrentNTE, RequestedNTE = x.u.RequestedNTE, VendorReason = x.u.VendorReason, @@ -252,6 +273,78 @@ namespace SeaHaven.DataServices.Implementation .SumAsync(u => u.RequestedNTE, cancellationToken); } + // Approval queue read contract: set-based exposure aggregation. Dispatches map to + // work orders through the server-derived linkage (primary work order plus linked + // work orders), matching the ForWorkOrder scope; approved amounts are summed per + // dispatch in SQL and folded into per-work-order totals in memory (three set-based + // round trips, no query-per-work-order). + public async Task> GetApprovedExposureForWorkOrdersAsync( + IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) + { + var workOrderIdsScope = workOrderIds.Distinct().ToList(); + if (workOrderIdsScope.Count == 0) + return Array.Empty(); + + var primaryPairs = await _context.Dispatches + .Where(d => (d.IsDeleted == null || d.IsDeleted == false) + && d.WorkOrderId != null + && workOrderIdsScope.Contains(d.WorkOrderId.Value)) + .Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value }) + .ToListAsync(cancellationToken); + + var linkedPairs = await _context.DispatchWorkOrders + .Where(l => workOrderIdsScope.Contains(l.WorkOrderId) + && l.Dispatch != null + && (l.Dispatch.IsDeleted == null || l.Dispatch.IsDeleted == false)) + .Select(l => new { l.DispatchId, l.WorkOrderId }) + .ToListAsync(cancellationToken); + + var workOrdersByDispatch = primaryPairs + .Concat(linkedPairs) + .GroupBy(p => p.DispatchId) + .ToDictionary(g => g.Key, g => g.Select(p => p.WorkOrderId).ToHashSet()); + + if (workOrdersByDispatch.Count == 0) + return Array.Empty(); + + var dispatchIds = workOrdersByDispatch.Keys.ToList(); + var sums = await _context.DispatchUpliftRequests + .Where(u => (u.IsDeleted == null || u.IsDeleted == false) + && dispatchIds.Contains(u.DispatchId) + && (u.Status == "Approved" || u.Status == "NoApprovalRequired")) + .GroupBy(u => u.DispatchId) + .Select(g => new + { + DispatchId = g.Key, + AutoApproved = g.Where(x => x.Status == "NoApprovalRequired") + .Sum(x => (decimal?)x.RequestedNTE), + AdminApproved = g.Where(x => x.Status == "Approved") + .Sum(x => (decimal?)x.RequestedNTE) + }) + .ToListAsync(cancellationToken); + + var totals = new Dictionary(); + foreach (var sum in sums) + { + if (!workOrdersByDispatch.TryGetValue(sum.DispatchId, out var linkedWorkOrders)) + continue; + + foreach (var workOrderId in linkedWorkOrders) + { + if (!totals.TryGetValue(workOrderId, out var total)) + { + total = new WorkOrderUpliftExposureData { WorkOrderId = workOrderId }; + totals[workOrderId] = total; + } + + total.AutoApprovedTotal += sum.AutoApproved ?? 0m; + total.AdminApprovedTotal += sum.AdminApproved ?? 0m; + } + } + + return totals.Values.ToList(); + } + public Task> GetPendingForWorkOrderAsync( int workOrderId, CancellationToken cancellationToken) diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 1c4062b..15be0ab 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -18,6 +18,12 @@ namespace SeaHaven.DataServices.Interfaces Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + // Approval queue read contract: set-based per-work-order exposure totals covering + // auto-approved and admin-approved uplift amounts (pending, rejected, + // cancelled/withdrawn, and revoked requests are excluded). Work orders absent + // from the result have no approved exposure. + Task> GetApprovedExposureForWorkOrdersAsync( + IReadOnlyCollection workOrderIds, CancellationToken cancellationToken); Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); // SH-101: active = Pending or ChangesRequested (the only states that block a new request). Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs index c9893ee..5333f07 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -8,6 +8,11 @@ namespace SeaHaven.Services.DTOs public string? PONumber { get; set; } public int? WorkOrderId { get; set; } public string? VendorCompanyName { get; set; } + // Approval queue read contract: flattened work-order context and requester. + public string? WorkOrderNumber { get; set; } + public string? WorkOrderSite { get; set; } + public string? WorkOrderService { get; set; } + public string? RequestedByName { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } public decimal Delta { get; set; } @@ -26,6 +31,12 @@ namespace SeaHaven.Services.DTOs public DateTime? ExpiresAt { get; set; } public string? NotificationStatus { get; set; } public string? NotificationError { get; set; } + // Approved-on-WO exposure totals for this item's work order: auto-approved and + // admin-approved amounts; pending, rejected, cancelled/withdrawn, and revoked + // requests are excluded. + public decimal WorkOrderAutoApprovedTotal { get; set; } + public decimal WorkOrderAdminApprovedTotal { get; set; } + public decimal WorkOrderApprovedExposureTotal { get; set; } } public class UpliftListResultDTO diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 2cfe96f..034f102 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -42,6 +42,10 @@ namespace SeaHaven.Services.Implementation PONumber = r.PONumber, WorkOrderId = r.WorkOrderId, VendorCompanyName = r.VendorCompanyName, + WorkOrderNumber = r.WorkOrderNumber, + WorkOrderSite = r.WorkOrderSiteCode, + WorkOrderService = r.WorkOrderService, + RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName), CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), @@ -61,6 +65,26 @@ namespace SeaHaven.Services.Implementation NotificationError = r.NotificationError }).ToList(); + // Approved-on-WO exposure totals are aggregated once for the whole page + // (single set-based data-service call) and then attached per item. + var workOrderIds = mapped + .Where(i => i.WorkOrderId.HasValue) + .Select(i => i.WorkOrderId!.Value) + .Distinct() + .ToList(); + var exposureByWorkOrder = (await _upliftData + .GetApprovedExposureForWorkOrdersAsync(workOrderIds, cancellationToken)) + .ToDictionary(e => e.WorkOrderId); + foreach (var item in mapped) + { + if (!item.WorkOrderId.HasValue) continue; + if (!exposureByWorkOrder.TryGetValue(item.WorkOrderId.Value, out var exposure)) continue; + + item.WorkOrderAutoApprovedTotal = exposure.AutoApprovedTotal; + item.WorkOrderAdminApprovedTotal = exposure.AdminApprovedTotal; + item.WorkOrderApprovedExposureTotal = exposure.AutoApprovedTotal + exposure.AdminApprovedTotal; + } + return new UpliftListResultDTO { Total = total, @@ -275,6 +299,19 @@ namespace SeaHaven.Services.Implementation return false; } + // Approval queue read contract: the requester label prefers the recorded + // requester (vendor or internal display name captured at creation) and falls + // back to the creating user's name resolved server-side. + private static string? ResolveRequestedByName(string? vendorName, string? firstName, string? lastName) + { + if (!string.IsNullOrWhiteSpace(vendorName)) + return vendorName; + + var composed = string.Join(" ", new[] { firstName, lastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + return composed.Length > 0 ? composed : null; + } + private string[] RolesForTier(int requiredTier) => requiredTier switch { 1 => _approvalsOptions.Tier1Roles, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index f53f1ff..12ddd97 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -441,6 +441,7 @@ public class WorkOrderBoardCancelServiceTests public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); From 104439c23984fd4c9737bd979607fc2183a21f72 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:07:31 -0300 Subject: [PATCH 06/22] fix(dashboard): serialise KPI counts in Stats response (SH-353) GetKpiCountsAsync computes ScheduledTomorrow, PendingUplifts and AvetaPending and DashboardStatsDTO carries them, but the Stats endpoint's anonymous wire object never serialised them, so the tiles had nothing to read. Add scheduledTomorrow, pendingUplifts and avetaPending to the response. Add a DashboardServiceTests case covering GetKpiCountsAsync end to end: it asserts all three counts and pins the pendingUplifts semantics to Status "Pending" only, excluding "ChangesRequested" (which is back with the vendor, not awaiting an approval decision). --- .../DashboardServiceTests.cs | 47 +++++++++++++++++++ .../Controllers/DashboardController.cs | 3 ++ 2 files changed, 50 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 54b8886..1b2afb4 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -99,6 +99,53 @@ public class DashboardServiceTests stats.Completed.Should().Be(1); } + [Fact] + public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + var tomorrow = today.AddDays(1); + + // Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue) + }); + + // Aveta-pending tile: Aveta-required order in the today/tomorrow window + // with no Aveta attachment. Its Dispatch anchors the uplift requests below. + var avetaOrder = new WorkOrder + { + AccountId = 1, + AvetaRequired = true, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }; + ctx.workOrders.Add(avetaOrder); + ctx.SaveChanges(); + + var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false }; + ctx.Set().Add(dispatch); + ctx.SaveChanges(); + + // Pending-uplifts tile: only Status == "Pending" is an outstanding uplift + // awaiting an approval decision. A "ChangesRequested" request has been sent + // back to the vendor and must NOT be counted (SH-353 review decision). + ctx.Set().AddRange( + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false }, + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.ScheduledTomorrow.Should().Be(1); + stats.AvetaPending.Should().Be(1); + stats.PendingUplifts.Should().Be(1); + } + [Fact] public async Task GetStatsAsync_MissingScopeFailsClosed() { diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 5551a5d..483f891 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -30,6 +30,9 @@ namespace Api.SeaHavenIndustries.Controllers open = stats.Open, notDispatched = stats.NotDispatched, completed = stats.Completed, + scheduledTomorrow = stats.ScheduledTomorrow, + pendingUplifts = stats.PendingUplifts, + avetaPending = stats.AvetaPending, breakdown = stats.Breakdown, dueCount = stats.DueCount, completedDueCount = stats.CompletedDueCount, From d366f319e9343b7f153970b6650513ea425a6083 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:11:26 -0300 Subject: [PATCH 07/22] feat(uplifts): add approval decision actions (SH-210) --- .../UpliftControllerTests.cs | 43 +++++++++++++++ .../UpliftServiceRefusedTests.cs | 53 +++++++++++++++++++ .../Controllers/UpliftController.cs | 29 ++++++++++ .../Implementation/UpliftDataService.cs | 21 ++++++++ .../Interfaces/IUpliftDataService.cs | 1 + .../Implementation/UpliftService.cs | 40 +++++++++++++- .../Interfaces/IUpliftService.cs | 1 + .../WorkOrderBoardCancelServiceTests.cs | 1 + 8 files changed, 188 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index dc634aa..7d49f24 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -117,6 +117,49 @@ public class UpliftControllerTests nf.Value.Should().BeOfType(); } + [Fact] + public async Task Revoke_WithReason_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny())) + .ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" }); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke( + 7, + new UpliftController.DecisionRequest { Note = "Policy change" }); + + result.Should().BeOfType(); + service.Verify(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Revoke_WithoutReason_ReturnsBadRequest() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + string.Empty, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("reason required")); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke(7, null); + + result.Should().BeOfType(); + } + [Fact] public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() { diff --git a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs index 9fa989c..a420208 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; @@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests; public sealed class UpliftServiceRefusedTests { + [Fact] + public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow() + { + var request = new DispatchUpliftRequest + { + Id = 7, + DispatchId = 42, + Status = "Approved", + RequiredTier = 1, + RequestedNTE = 900m + }; + var upliftData = new Mock(); + upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny())) + .ReturnsAsync(request); + upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny())) + .ReturnsAsync(77); + var workOrderFlow = new Mock(); + workOrderFlow.Setup(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" }); + + var service = new UpliftService( + upliftData.Object, + Mock.Of(), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + workOrderFlow.Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, + "test")); + + var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None); + + result.Id.Should().Be(7); + result.Status.Should().Be("Revoked"); + workOrderFlow.Verify(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny()), Times.Once); + } + [Fact] public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest() { diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index cc2d9ca..fcba99f 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("{id:int}/revoke")] + public async Task Revoke( + int id, + [FromBody] DecisionRequest? body, + CancellationToken cancellationToken = default) + { + try + { + var result = await _upliftService.RevokeAsync( + User, + id, + body?.Note ?? string.Empty, + cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to revoke this uplift") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") }); + } + } + [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 8d7ac66..6c52434 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -221,6 +221,27 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); } + public async Task GetWorkOrderIdForUpliftAsync( + int upliftRequestId, + CancellationToken cancellationToken) + { + var link = await _context.DispatchUpliftRequests + .Where(u => u.Id == upliftRequestId + && (u.IsDeleted == null || u.IsDeleted == false) + && u.Dispatch != null + && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)) + .Select(u => new + { + PrimaryWorkOrderId = u.Dispatch!.WorkOrderId, + LinkedWorkOrderId = u.Dispatch.DispatchWorkOrders! + .Select(dispatchWorkOrder => (int?)dispatchWorkOrder.WorkOrderId) + .FirstOrDefault() + }) + .FirstOrDefaultAsync(cancellationToken); + + return link?.PrimaryWorkOrderId ?? link?.LinkedWorkOrderId; + } + public async Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 15be0ab..9ca8a5d 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -11,6 +11,7 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken); Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); // SH-101: server-side join of an uplift request with its linked evidence document. // Returns null when the request, the linked evidence, or the dispatch linkage is absent. diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 034f102..9e86960 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -15,19 +15,22 @@ namespace SeaHaven.Services.Implementation private readonly IVendorDocumentStoragePort _documentStorage; private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; + private readonly IWorkOrderUpliftService? _workOrderUpliftService; public UpliftService( IUpliftDataService upliftData, IDispatchDataService dispatchData, IVendorDocumentStoragePort documentStorage, TimeProvider timeProvider, - IOptions approvalsOptions) + IOptions approvalsOptions, + IWorkOrderUpliftService? workOrderUpliftService = null) { _upliftData = upliftData; _dispatchData = dispatchData; _documentStorage = documentStorage; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; + _workOrderUpliftService = workOrderUpliftService; } public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) @@ -180,6 +183,41 @@ namespace SeaHaven.Services.Implementation public Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) => RejectInternalAsync(user, id, note, "reject", cancellationToken); + public async Task RevokeAsync( + ClaimsPrincipal user, + int id, + string reason, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new InvalidOperationException("A reason is required when revoking an approved uplift"); + + var request = await _upliftData.GetByIdAsync(id, cancellationToken); + if (request == null) + throw new KeyNotFoundException("Uplift request not found"); + if (!string.Equals(UpliftStatus.ToCanonical(request.Status), UpliftStatus.Approved, StringComparison.Ordinal)) + throw new InvalidOperationException($"Cannot revoke a '{UpliftStatus.ToCanonical(request.Status)}' uplift request"); + if (!user.IsInRole("Admin")) + throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift"); + if (_workOrderUpliftService == null) + throw new InvalidOperationException("The work-order uplift flow is unavailable"); + + var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(id, cancellationToken); + if (!workOrderId.HasValue) + throw new KeyNotFoundException("Work order not found"); + + var revoked = await _workOrderUpliftService.RevokeAsync( + workOrderId.Value, + id, + new RevokeWorkOrderUpliftRequestDto { Reason = reason.Trim() }, + user, + cancellationToken); + if (revoked == null) + throw new KeyNotFoundException("Work order not found"); + + return new UpliftDecisionResultDTO { Id = revoked.Id, Status = UpliftStatus.Revoked }; + } + private async Task RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(note)) diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs index 04dcadc..c84142f 100644 --- a/SeaHaven.Services/Interfaces/IUpliftService.cs +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); // SH-101: canonical reject (alias of deny; writes Rejected). Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + Task RevokeAsync(ClaimsPrincipal user, int id, string reason, CancellationToken cancellationToken); // SH-101: internal request-changes route (note + tier authorization + audit). Task RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken); // SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request. diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 12ddd97..bfc163f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -436,6 +436,7 @@ public class WorkOrderBoardCancelServiceTests public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); From 22308f21f6a4c590a97606595daad36d3c5bfab3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:15:20 -0300 Subject: [PATCH 08/22] fix(dashboard): resolve region zones from full state names (SH-348) Locations store State as either a two-letter postal code or a full state name (the location list filter expands codes to both forms via UsStateCodes.ExpandStorageValues, and dev fixtures carry State = "indiana"). DashboardRegions.Resolve only matched the two-letter key, so every location stored as a full name fell into Unmapped/Other and the East/Central/West/ California bars undercounted. Normalise the stored value through a new UsStateCodes.ToCode, which accepts a code or a full name and returns the canonical postal code, before the zone lookup. Extend the region test with full-name storage forms and add an invariant asserting every US state and its full-name form resolves to a canonical bucket. --- .../DashboardServiceTests.cs | 31 +++++++++++++++++-- SeaHaven.Services/Helpers/DashboardRegions.cs | 5 +-- SeaHaven.Services/Helpers/UsStateCodes.cs | 21 +++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index c7c6410..4b6b2c5 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -309,11 +309,17 @@ public class DashboardServiceTests ctx.Locations.AddRange( new Locations { Id = 1, State = "NY" }, new Locations { Id = 2, State = "ca" }, - new Locations { Id = 3, State = "XX" }); + new Locations { Id = 3, State = "XX" }, + new Locations { Id = 4, State = "indiana" }, + new Locations { Id = 5, State = "NEW YORK" }, + new Locations { Id = 6, State = "California" }); ctx.workOrders.AddRange( new WorkOrder { AccountId = 1, LocationId = 1 }, new WorkOrder { AccountId = 1, LocationId = 2 }, new WorkOrder { AccountId = 1, LocationId = 3 }, + new WorkOrder { AccountId = 1, LocationId = 4 }, + new WorkOrder { AccountId = 1, LocationId = 5 }, + new WorkOrder { AccountId = 1, LocationId = 6 }, new WorkOrder { AccountId = 1, LocationId = null }); await ctx.SaveChangesAsync(); @@ -322,6 +328,27 @@ public class DashboardServiceTests result.Items.Select(row => row.Region).Should().Equal( "East", "Central", "West", "California", "Unmapped/Other"); - result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2); + // NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California; + // "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not + // fall through to Unmapped/Other (SH-348 review). + result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2); + } + + [Fact] + public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket() + { + foreach (var code in UsStateCodes.All) + { + var byCode = DashboardRegions.Resolve(code); + byCode.Should().NotBe( + "Unmapped/Other", + $"state code {code} must belong to a canonical region"); + + var fullName = UsStateCodes.ExpandStorageValues(new[] { code }) + .First(value => value != code); + DashboardRegions.Resolve(fullName).Should().Be( + byCode, + $"the full-name storage form of {code} must resolve to the same region as the code"); + } } } diff --git a/SeaHaven.Services/Helpers/DashboardRegions.cs b/SeaHaven.Services/Helpers/DashboardRegions.cs index f3b5fae..d41ae83 100644 --- a/SeaHaven.Services/Helpers/DashboardRegions.cs +++ b/SeaHaven.Services/Helpers/DashboardRegions.cs @@ -9,10 +9,11 @@ public static class DashboardRegions public static string Resolve(string? state) { - if (string.IsNullOrWhiteSpace(state)) + var code = UsStateCodes.ToCode(state); + if (code is null) return "Unmapped/Other"; - return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region) + return StateToRegion.TryGetValue(code, out var region) ? region : "Unmapped/Other"; } diff --git a/SeaHaven.Services/Helpers/UsStateCodes.cs b/SeaHaven.Services/Helpers/UsStateCodes.cs index 9d64a1a..54c32ea 100644 --- a/SeaHaven.Services/Helpers/UsStateCodes.cs +++ b/SeaHaven.Services/Helpers/UsStateCodes.cs @@ -56,8 +56,29 @@ namespace SeaHaven.Services.Helpers ["WY"] = "WYOMING" }; + private static readonly IReadOnlyDictionary CodesByName = + Names.ToDictionary(pair => pair.Value, pair => pair.Key, StringComparer.Ordinal); + public static IReadOnlySet All { get; } = new HashSet(Names.Keys, StringComparer.Ordinal); + /// + /// Normalises a stored state value to its two-letter postal code. Accepts either a + /// postal code ("IN") or a full state name ("Indiana"), case-insensitively, matching + /// the storage forms produced by . Returns null when + /// the value is blank or is not a recognised US state. + /// + public static string? ToCode(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var normalized = value.Trim().ToUpperInvariant(); + if (All.Contains(normalized)) + return normalized; + + return CodesByName.TryGetValue(normalized, out var code) ? code : null; + } + public static IReadOnlyCollection ExpandStorageValues(IEnumerable codes) { return codes From 93ecbbd410a67c280c5c47a222f5360ef3b1e94f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:25:45 -0300 Subject: [PATCH 09/22] test(dashboard): guard Stats wire object for KPI counts (SH-353) The added service test asserts DashboardStatsDTO, which already carried ScheduledTomorrow, PendingUplifts and AvetaPending, so it does not guard the regression that was actually shipped: the DashboardController Stats anonymous response dropping those keys and leaving the tiles empty. Add DashboardControllerTests.GetStats_SerialisesKpiCountsOnWireObject, which drives the controller and asserts the three keys are present on the serialised wire object, so re-dropping any of them fails a test. Also reword the pending-uplifts comment in DashboardServiceTests: the Pending-only-vs-ChangesRequested exclusion is the behaviour the code ships today, an open product call, not a settled review decision. --- .../DashboardControllerTests.cs | 54 +++++++++++++++++++ .../DashboardServiceTests.cs | 6 ++- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs new file mode 100644 index 0000000..4ef3b58 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DashboardControllerTests +{ + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + private static DashboardController NewController(Mock service) => + new(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + // Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and + // DashboardStatsDTO already carried these three counts; the regression this + // pins is the controller's anonymous response silently dropping them, which + // is what left the tiles with nothing to read. + [Fact] + public async Task GetStats_SerialisesKpiCountsOnWireObject() + { + var service = new Mock(); + service.Setup(s => s.GetStatsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new DashboardStatsDTO + { + ScheduledTomorrow = 3, + PendingUplifts = 5, + AvetaPending = 7 + }); + + var result = await NewController(service).GetStats( + new DashboardStatsQueryDTO(), CancellationToken.None); + + var body = result.Should().BeOfType().Subject.Value!; + ((int)Prop(body, "scheduledTomorrow")).Should().Be(3); + ((int)Prop(body, "pendingUplifts")).Should().Be(5); + ((int)Prop(body, "avetaPending")).Should().Be(7); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 1b2afb4..1d4e8ec 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -131,8 +131,10 @@ public class DashboardServiceTests ctx.SaveChanges(); // Pending-uplifts tile: only Status == "Pending" is an outstanding uplift - // awaiting an approval decision. A "ChangesRequested" request has been sent - // back to the vendor and must NOT be counted (SH-353 review decision). + // awaiting an approval decision. This test pins the behaviour the code + // ships today: a "ChangesRequested" request has been sent back to the + // vendor, so it is NOT counted. Whether pendingUplifts should also include + // ChangesRequested is an open product call, not a settled review decision. ctx.Set().AddRange( new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false }, new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false }); From 660ebac628ca1ab7b76ba57fd534b7504764225e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:45:15 -0300 Subject: [PATCH 10/22] feat(locations): support site registry queries (SH-330) --- .../LocationControllerTests.cs | 96 +++++++++++++- .../LocationDataServiceTests.cs | 125 ++++++++++++++++++ .../LocationServiceTests.cs | 105 ++++++++++++++- .../LocationSiteContactsTests.cs | 2 +- .../Controllers/LocationController.cs | 6 +- .../Implementation/LocationDataService.cs | 42 +++++- .../Interfaces/ILocationDataService.cs | 6 +- SeaHaven.Services/DTOs/LocationDTOs.cs | 1 + .../Implementation/LocationService.cs | 45 ++++++- .../Interfaces/ILocationService.cs | 2 +- .../WorkOrderAccountScopeTests.cs | 4 +- 11 files changed, 410 insertions(+), 24 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index 47e53dd..888f092 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -26,7 +26,7 @@ public class LocationControllerTests public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List { new() { Id = 1, Name = "Site" } }, @@ -49,7 +49,7 @@ public class LocationControllerTests public async Task GetLocationList_PassesStatesFilterToService() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List(), @@ -58,7 +58,7 @@ public class LocationControllerTests PageSize = 10 }); - var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None); + var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None); result.Should().BeOfType(); service.VerifyAll(); @@ -68,13 +68,13 @@ public class LocationControllerTests public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException(new[] { new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.") })); - var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None); + var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; @@ -200,4 +200,90 @@ public class LocationControllerTests var notFound = result.Should().BeOfType().Subject; notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Location not found"); } + + [Fact] + public async Task GetLocationList_ForwardsSortParamsToService() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc")) + .ReturnsAsync(new PagedResult { Items = new List(), TotalCount = 0, Page = 1, PageSize = 10 }); + + await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None); + + service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc"), Times.Once); + } + + [Fact] + public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ValidationException(new[] + { + new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.") + })); + + var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Validation Error"); + response.Message.Should().Contain("sortBy"); + } + + [Fact] + public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), null, null)) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() + { + Id = 1, + Name = "STL8", + Address1 = "9 River Rd", + City = "St. Louis", + State = "MO", + Zip = "63101", + PhoneNumber = "555-0100", + Email = "poc@example.com", + AccountId = 3, + AccountName = "Acme Co", + Contacts = new List + { + new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" }, + new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" } + } + } + }, + TotalCount = 1, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value); + var row = json.RootElement.GetProperty("Data").EnumerateArray().Single(); + + row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field"); + row.GetProperty("Address").GetString().Should().Be("9 River Rd"); + row.GetProperty("City").GetString().Should().Be("St. Louis"); + row.GetProperty("State").GetString().Should().Be("MO"); + row.GetProperty("ZipCode").GetString().Should().Be("63101"); + row.GetProperty("Phone").GetString().Should().Be("555-0100"); + row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com"); + row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field"); + row.GetProperty("AccountId").GetInt32().Should().Be(3); + row.GetProperty("ClientName").GetString().Should().Be("Acme Co"); + row.GetProperty("AccountName").GetString().Should().Be("Acme Co"); + var contacts = row.GetProperty("Contacts"); + contacts.GetArrayLength().Should().Be(2); + contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane"); + contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs index 0dafeaf..595f78e 100644 --- a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs @@ -86,4 +86,129 @@ public class LocationDataServiceTests totalCount.Should().Be(2); items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO"); } + + private static async Task SeedRegistryAsync(ApplicationDbContext ctx) + { + ctx.Accounts.AddRange( + new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false }, + new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false }); + ctx.Locations.AddRange( + new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 }, + new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 }, + new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 }, + new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 }); + ctx.Contacts.AddRange( + new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" }, + new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" }, + new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" }, + new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true }, + new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" }); + await ctx.SaveChangesAsync(); + } + + [Theory] + [InlineData("STL", new[] { 10 }, "site code")] + [InlineData("Borealis", new[] { 11, 13 }, "client name")] + [InlineData("Prairie", new[] { 11 }, "street address")] + [InlineData("Miami", new[] { 13 }, "city")] + public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, term, null, CancellationToken.None); + + totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}"); + items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds); + } + + [Theory] + [InlineData("code", 11, 12, 13, 10)] + [InlineData("client", 10, 12, 11, 13)] + [InlineData("address", 13, 11, 12, 10)] + [InlineData("city", 11, 12, 13, 10)] + [InlineData("state", 13, 12, 10, 11)] + [InlineData("poc", 13, 12, 10, 11)] + public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak( + string sortBy, params int[] expectedIds) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc"); + + items.Select(l => l.Id).Should().ContainInOrder(expectedIds); + } + + [Fact] + public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc"); + + // DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must + // sort last; including the deleted row ("Removed Poc") would instead place it second. + items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11); + } + + [Fact] + public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc"); + + items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 }); + } + + [Fact] + public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways"); + + items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8"); + } + + [Fact] + public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 20, Name = "HOU1", City = "Dallas" }, + new Locations { Id = 21, Name = "AUS3", City = "Dallas" }, + new Locations { Id = 22, Name = "ELP4", City = "Dallas" }); + await ctx.SaveChangesAsync(); + + var service = new LocationDataService(ctx); + + var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc"); + var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc"); + + totalCount.Should().Be(3); + page1.Select(l => l.Id).Should().ContainInOrder(20, 21); + page2.Select(l => l.Id).Should().ContainInOrder(22); + } + + [Fact] + public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None); + + items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index c8986b6..2c986c8 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -131,8 +131,8 @@ public class LocationServiceTests { var data = new Mock(); IReadOnlyCollection? captured = default; - data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny())) - .Callback?, CancellationToken>((_, _, _, states, _) => captured = states) + data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states) .ReturnsAsync((new List(), 0)); await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None); @@ -149,8 +149,8 @@ public class LocationServiceTests { var data = new Mock(); IReadOnlyCollection? captured = default; - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) - .Callback?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter) .ReturnsAsync((new List(), 0)); var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None); @@ -173,14 +173,14 @@ public class LocationServiceTests && e.ErrorMessage.Contains("ZZ") && e.ErrorMessage.Contains("QQ") && !e.ErrorMessage.Contains("TX")); - data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny()), Times.Never); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes() { var data = new Mock(); - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((new List(), 0)); var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All); @@ -533,4 +533,97 @@ public class LocationServiceTests again.Should().BeFalse(); ctx.Locations.Should().BeEmpty(); } + + [Fact] + public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService() + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) => + { + capturedSort = sortBy; + capturedDirection = sortDirection; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC "); + + capturedSort.Should().Be("poc"); + capturedDirection.Should().Be("desc"); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", " ")] + public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults( + string? sortBy, + string? sortDirection) + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) => + { + capturedSort = s; + capturedDirection = d; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection); + + capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering"); + capturedDirection.Should().Be("asc"); + } + + [Theory] + [InlineData("rating")] + [InlineData("password; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortBy" + && e.ErrorMessage.Contains("code") + && e.ErrorMessage.Contains("poc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("ascending")] + [InlineData("DESC; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortDirection" + && e.ErrorMessage.Contains("asc") + && e.ErrorMessage.Contains("desc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows() + { + using var ctx = NewContext(); + SeedAccount(ctx, 9, "Acme Co"); + SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9; + await ctx.SaveChangesAsync(); + + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None); + + var row = page.Items.Should().ContainSingle().Subject; + row.AccountId.Should().Be(9); + row.AccountName.Should().Be("Acme Co"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs index 5b9cf9d..f4aef07 100644 --- a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs @@ -430,7 +430,7 @@ public class LocationSiteContactsTests public async Task List_LoadsPageContactsInSingleBatchedRead() { var data = new Mock(); - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((new List { new() { Id = 1, Name = "One" }, diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 9e970a6..a956819 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetLocationList")] - public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default) + public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default) { try { - var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken); + var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection); var data = pagedResult.Items.Select(l => new { @@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers ContactEmail = l.Email, Status = l.Status, AccountId = l.AccountId, + ClientName = l.AccountName, + AccountName = l.AccountName, Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList() }); diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 71be6ec..11cb89e 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation int pageSize, string? search, IReadOnlyCollection? states, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? sortBy = null, + string? sortDirection = null) { - var query = _context.Locations.AsNoTracking(); + IQueryable query = _context.Locations + .AsNoTracking() + .Include(l => l.Account); if (!string.IsNullOrWhiteSpace(search)) { query = query.Where(l => l.Name!.Contains(search) || - l.City!.Contains(search)); + l.Address1!.Contains(search) || + l.City!.Contains(search) || + l.Account != null && l.Account.Name!.Contains(search)); } if (states is { Count: > 0 }) @@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper())); } + query = ApplyListSort(query, sortBy, sortDirection); + var totalCount = await query.CountAsync(cancellationToken); var items = await query - .OrderBy(l => l.Name) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); @@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + private static IQueryable ApplyListSort(IQueryable query, string? sortBy, string? sortDirection) + { + var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase); + IOrderedQueryable ordered = (sortBy?.Trim().ToLowerInvariant()) switch + { + "code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name), + "client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name), + "address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1), + "city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City), + "state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State), + "poc" => descending + ? query.OrderByDescending(FirstActiveContactName) + : query.OrderBy(FirstActiveContactName), + _ => query.OrderBy(l => l.Name) + }; + + return ordered.ThenBy(l => l.Id); + } + + private static readonly System.Linq.Expressions.Expression> FirstActiveContactName = + l => l.Contacts + .Where(c => c.IsDeleted != true) + .OrderBy(c => c.SiteContactOrder) + .ThenBy(c => c.Id) + .Select(c => c.FirstName) + .FirstOrDefault(); + public async Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) { return await _context.Locations diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 582fb4b..5a068c1 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces Task ExistsAsync(int id); Task CountAsync(); - Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken); + /// + /// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order. + /// Sort options trail the token so existing call sites stay source-compatible. + /// + Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null); Task GetDetailByIdAsync(int id, CancellationToken cancellationToken); Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 1e437ba..58f0878 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs public string? Email { get; set; } public string? Status { get; set; } public int? AccountId { get; set; } + public string? AccountName { get; set; } public DateTime? CreatedDate { get; set; } public string? CreatedBy { get; set; } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index e140ece..64b275a 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -166,11 +166,15 @@ namespace SeaHaven.Services.Implementation int pageSize, string? search, string? states = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? sortBy = null, + string? sortDirection = null) { var stateFilter = NormalizeStateFilter(states); var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter); - var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken); + var sort = NormalizeSortColumn(sortBy); + var direction = NormalizeSortDirection(sortDirection); + var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken, sort, direction); // SH-138: one bounded contact read for the whole page, never per row. var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken); @@ -184,6 +188,42 @@ namespace SeaHaven.Services.Implementation }; } + internal static readonly IReadOnlyCollection SortableColumns = new[] { "code", "client", "address", "city", "state", "poc" }; + + internal static string? NormalizeSortColumn(string? sortBy) + { + if (string.IsNullOrWhiteSpace(sortBy)) + return null; + + var normalized = sortBy.Trim().ToLowerInvariant(); + if (!SortableColumns.Contains(normalized)) + { + throw new ValidationException(new[] + { + new ValidationFailure("sortBy", $"sortBy must be one of: {string.Join(", ", SortableColumns)}.") + }); + } + + return normalized; + } + + internal static string NormalizeSortDirection(string? sortDirection) + { + if (string.IsNullOrWhiteSpace(sortDirection)) + return "asc"; + + var normalized = sortDirection.Trim().ToLowerInvariant(); + if (normalized != "asc" && normalized != "desc") + { + throw new ValidationException(new[] + { + new ValidationFailure("sortDirection", "sortDirection must be asc or desc.") + }); + } + + return normalized; + } + internal static IReadOnlyCollection? NormalizeStateFilter(string? states) { if (string.IsNullOrWhiteSpace(states)) @@ -503,6 +543,7 @@ namespace SeaHaven.Services.Implementation Email = location.Email, Status = location.Status, AccountId = location.AccountId, + AccountName = location.Account?.Name, CreatedDate = location.CreatedDate, CreatedBy = location.createdby, Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts) diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 661adde..f05620a 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces Task LocationExistsAsync(int id); Task GetTotalLocationCountAsync(); - Task> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default); + Task> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default, string? sortBy = null, string? sortDirection = null); Task GetLocationDetailAsync(int id, CancellationToken cancellationToken); Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 2b11906..5acde4c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests public Task ExistsAsync(int id) => _inner.ExistsAsync(id); public Task CountAsync() => _inner.CountAsync(); public Task<(List Items, int TotalCount)> GetListPagedAsync( - int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken) - => _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken); + int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null) + => _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection); public Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) => _inner.GetDetailByIdAsync(id, cancellationToken); public Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken) From 738c9eaf4503aa79732fbd0aa035efd79cbed3e6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 21:27:05 -0300 Subject: [PATCH 11/22] feat(work-orders): add region filtering (SH-348) --- .../Helpers/WorkOrderBoardQueryFilters.cs | 34 +++++++++ .../Helpers/WorkOrderBoardRegions.cs | 45 ++++++++++++ .../WorkOrderAdvancedSearchDataService.cs | 1 + .../WorkOrderAdvancedSearchModels.cs | 1 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 1 + SeaHaven.Services/Helpers/DashboardRegions.cs | 70 +++---------------- .../WorkOrderAdvancedSearchService.cs | 1 + .../WorkOrderBoardSearchTests.cs | 40 +++++++++++ 8 files changed, 133 insertions(+), 60 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 7ff26ba..71c4d94 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -121,6 +121,40 @@ namespace SeaHaven.DataServices.Helpers return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode)); } + public static IQueryable ApplyRegionFilter( + IQueryable query, + IReadOnlyList? regions) + { + if (regions == null || regions.Count == 0) + return query; + + var normalized = regions + .Where(region => !string.IsNullOrWhiteSpace(region)) + .Select(region => region.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (normalized.Count == 0) + return query; + + var knownRegions = normalized + .Where(region => WorkOrderBoardRegions.Names.Contains(region) && region != "Unmapped/Other") + .SelectMany(WorkOrderBoardRegions.StorageValuesFor) + .Distinct(StringComparer.Ordinal) + .ToList(); + var includeUnmapped = normalized.Contains("Unmapped/Other", StringComparer.Ordinal); + var hasKnownRegion = knownRegions.Count > 0; + + if (!hasKnownRegion && !includeUnmapped) + return query.Where(_ => false); + + return query.Where(w => + (hasKnownRegion && w.Locations != null && w.Locations.State != null && knownRegions.Contains(w.Locations.State)) + || (includeUnmapped && (w.Locations == null + || w.Locations.State == null + || w.Locations.State == "" + || !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State)))); + } + public static IQueryable ApplyStatusFilter( IQueryable query, IReadOnlyList? statuses) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs new file mode 100644 index 0000000..0ca39ec --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs @@ -0,0 +1,45 @@ +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardRegions + { + public static readonly IReadOnlyList Names = + ["East", "Central", "West", "California", "Unmapped/Other"]; + + private static readonly IReadOnlyDictionary RegionStateCodes = + new Dictionary(StringComparer.Ordinal) + { + ["East"] = ["CT", "DE", "GA", "ME", "MD", "MA", "NH", "NJ", "NY", "NC", "PA", "RI", "SC", "VT", "VA", "WV"], + ["Central"] = ["AL", "AR", "FL", "IL", "IN", "IA", "KS", "KY", "LA", "MI", "MN", "MS", "MO", "NE", "ND", "OH", "OK", "SD", "TN", "WI"], + ["West"] = ["AK", "AZ", "CO", "HI", "ID", "MT", "NV", "NM", "OR", "TX", "UT", "WA", "WY"], + ["California"] = ["CA"] + }; + + private static readonly IReadOnlyDictionary StateNames = + new Dictionary(StringComparer.Ordinal) + { + ["AL"] = "ALABAMA", ["AK"] = "ALASKA", ["AZ"] = "ARIZONA", ["AR"] = "ARKANSAS", + ["CA"] = "CALIFORNIA", ["CO"] = "COLORADO", ["CT"] = "CONNECTICUT", ["DE"] = "DELAWARE", + ["FL"] = "FLORIDA", ["GA"] = "GEORGIA", ["HI"] = "HAWAII", ["ID"] = "IDAHO", + ["IL"] = "ILLINOIS", ["IN"] = "INDIANA", ["IA"] = "IOWA", ["KS"] = "KANSAS", + ["KY"] = "KENTUCKY", ["LA"] = "LOUISIANA", ["ME"] = "MAINE", ["MD"] = "MARYLAND", + ["MA"] = "MASSACHUSETTS", ["MI"] = "MICHIGAN", ["MN"] = "MINNESOTA", ["MS"] = "MISSISSIPPI", + ["MO"] = "MISSOURI", ["MT"] = "MONTANA", ["NE"] = "NEBRASKA", ["NV"] = "NEVADA", + ["NH"] = "NEW HAMPSHIRE", ["NJ"] = "NEW JERSEY", ["NM"] = "NEW MEXICO", ["NY"] = "NEW YORK", + ["NC"] = "NORTH CAROLINA", ["ND"] = "NORTH DAKOTA", ["OH"] = "OHIO", ["OK"] = "OKLAHOMA", + ["OR"] = "OREGON", ["PA"] = "PENNSYLVANIA", ["RI"] = "RHODE ISLAND", ["SC"] = "SOUTH CAROLINA", + ["SD"] = "SOUTH DAKOTA", ["TN"] = "TENNESSEE", ["TX"] = "TEXAS", ["UT"] = "UTAH", + ["VT"] = "VERMONT", ["VA"] = "VIRGINIA", ["WA"] = "WASHINGTON", ["WV"] = "WEST VIRGINIA", + ["WI"] = "WISCONSIN", ["WY"] = "WYOMING" + }; + + public static IReadOnlyCollection StorageValuesFor(string region) + => RegionStateCodes.TryGetValue(region, out var codes) + ? codes.SelectMany(code => new[] { code, StateNames[code] }).ToArray() + : Array.Empty(); + + public static IReadOnlySet KnownStorageValues { get; } = + RegionStateCodes.Values + .SelectMany(codes => codes.SelectMany(code => new[] { code, StateNames[code] })) + .ToHashSet(StringComparer.Ordinal); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index ceac856..eeb28c5 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -24,6 +24,7 @@ namespace SeaHaven.DataServices.Implementation else baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); + baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 576af7a..2c0a627 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -9,6 +9,7 @@ namespace SeaHaven.DataServices.Interfaces DateOnly DateTo, bool UnscheduledOnly, IReadOnlyList? Sites, + IReadOnlyList? Regions, IReadOnlyList? Types, bool Overdue, IReadOnlyList? Dispatchers, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 8c04373..b90380c 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -73,6 +73,7 @@ namespace SeaHaven.Services.DTOs /// public bool UnscheduledOnly { get; set; } public List? Sites { get; set; } + public List? Regions { get; set; } public List? Types { get; set; } /// When true, include past-due (isPastDue) rows. Combined with Types via OR. public bool Overdue { get; set; } diff --git a/SeaHaven.Services/Helpers/DashboardRegions.cs b/SeaHaven.Services/Helpers/DashboardRegions.cs index f3b5fae..45f23aa 100644 --- a/SeaHaven.Services/Helpers/DashboardRegions.cs +++ b/SeaHaven.Services/Helpers/DashboardRegions.cs @@ -1,20 +1,25 @@ +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.Services.Helpers; public static class DashboardRegions { - public static readonly IReadOnlyList Names = - ["East", "Central", "West", "California", "Unmapped/Other"]; + public static readonly IReadOnlyList Names = WorkOrderBoardRegions.Names; public static string Resolve(string? state) { if (string.IsNullOrWhiteSpace(state)) return "Unmapped/Other"; - return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region) - ? region - : "Unmapped/Other"; + var normalized = state.Trim().ToUpperInvariant(); + foreach (var region in Names.Where(region => region != "Unmapped/Other")) + { + if (WorkOrderBoardRegions.StorageValuesFor(region).Contains(normalized)) + return region; + } + + return "Unmapped/Other"; } public static IReadOnlyDictionary Count( @@ -26,59 +31,4 @@ public static class DashboardRegions return counts; } - - private static readonly IReadOnlyDictionary StateToRegion = - new Dictionary(StringComparer.Ordinal) - { - ["CT"] = "East", - ["DE"] = "East", - ["GA"] = "East", - ["ME"] = "East", - ["MD"] = "East", - ["MA"] = "East", - ["NH"] = "East", - ["NJ"] = "East", - ["NY"] = "East", - ["NC"] = "East", - ["PA"] = "East", - ["RI"] = "East", - ["SC"] = "East", - ["VT"] = "East", - ["VA"] = "East", - ["WV"] = "East", - ["AL"] = "Central", - ["AR"] = "Central", - ["FL"] = "Central", - ["IL"] = "Central", - ["IN"] = "Central", - ["IA"] = "Central", - ["KS"] = "Central", - ["KY"] = "Central", - ["LA"] = "Central", - ["MI"] = "Central", - ["MN"] = "Central", - ["MS"] = "Central", - ["MO"] = "Central", - ["NE"] = "Central", - ["ND"] = "Central", - ["OH"] = "Central", - ["OK"] = "Central", - ["SD"] = "Central", - ["TN"] = "Central", - ["WI"] = "Central", - ["AK"] = "West", - ["AZ"] = "West", - ["CO"] = "West", - ["HI"] = "West", - ["ID"] = "West", - ["MT"] = "West", - ["NV"] = "West", - ["NM"] = "West", - ["OR"] = "West", - ["TX"] = "West", - ["UT"] = "West", - ["WA"] = "West", - ["WY"] = "West", - ["CA"] = "California" - }; } diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index 6c4e3da..fa97dc5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -49,6 +49,7 @@ namespace SeaHaven.Services.Implementation dateRange.To, query.UnscheduledOnly, query.Sites, + query.Regions, query.Types, query.Overdue, query.Dispatchers, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 721291a..033d3fb 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -228,6 +228,46 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Equal("BK5", result.Items.First().SiteCode); } + [Fact] + public async Task SearchAsync_FiltersByCanonicalRegionAndUnmappedBucket() + { + await using var context = CreateContext(); + var date = new DateTime(2026, 6, 23); + context.Locations.AddRange( + new Locations { Id = 1, State = "WA" }, + new Locations { Id = 2, State = "CALIFORNIA" }, + new Locations { Id = 3, State = "Atlantis" }, + new Locations { Id = 4, State = null }); + context.workOrders.AddRange( + new WorkOrder { Id = 1, LocationId = 1, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled }, + new WorkOrder { Id = 2, LocationId = 2, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled }, + new WorkOrder { Id = 3, LocationId = 3, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled }, + new WorkOrder { Id = 4, LocationId = 4, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, WorkOrderAccountTestHelpers.Resolver(context)); + var query = new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 6, 22), DateTo = new DateOnly(2026, 6, 30), + PageSize = 50 + }; + + query.Regions = new List { "West" }; + var west = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + Assert.Equal(new[] { 1 }, west.Items.Select(item => item.Id)); + + query.Regions = new List { "California" }; + var california = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + Assert.Equal(new[] { 2 }, california.Items.Select(item => item.Id)); + + query.Regions = new List { "Unmapped/Other" }; + var unmapped = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + Assert.Equal(new[] { 3, 4 }, unmapped.Items.Select(item => item.Id)); + } + [Fact] public async Task SearchAsync_ClampsPageSizeToMax() { From dca8d898cb7b69fd05b98c635ad26ce9817556bb Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 21:37:17 -0300 Subject: [PATCH 12/22] feat(dashboard): add scoped metrics contract (SH-347) --- .../DashboardServiceTests.cs | 255 +++++++++++++++++- .../Controllers/DashboardController.cs | 3 +- .../Implementation/DashboardDataService.cs | 4 +- SeaHaven.Services/DTOs/DashboardDTOs.cs | 10 + SeaHaven.Services/Helpers/DashboardMetrics.cs | 22 +- .../Implementation/DashboardService.cs | 59 ++-- 6 files changed, 324 insertions(+), 29 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index a383bd7..6bb5b38 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -221,11 +221,13 @@ public class DashboardServiceTests 1, CancellationToken.None); - result.TotalDispatchers.Should().Be(1); - result.Items.Should().ContainSingle(); - result.Items[0].DispatcherName.Should().Be("Ada One"); + result.TotalDispatchers.Should().Be(2); + result.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); result.Items[0].TotalCount.Should().Be(2); result.Items[0].OpenCount.Should().Be(1); + result.Items[1].TotalCount.Should().Be(0); + result.Items[1].OpenCount.Should().Be(0); result.PageSize.Should().Be(10); var adminResult = await NewService(ctx).GetWorkloadAsync( @@ -367,4 +369,251 @@ public class DashboardServiceTests result.Items[0].CompletionRate.Should().Be(100); result.Items[0].RescheduleRate.Should().Be(50); } + + [Fact] + public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(2); + } + + [Fact] + public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var service = NewService(ctx); + + var selected = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + selected.Total.Should().Be(3); + selected.Open.Should().Be(3); + + var mine = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "mine" }, + CancellationToken.None); + mine.Total.Should().Be(1); + mine.Completed.Should().Be(1); + + var all = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + all.Total.Should().Be(4); + } + + [Fact] + public async Task GetStatsAsync_ManagerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "manager-1", "Manager"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + + [Fact] + public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var user = AccountUser(1, "tech-1", "Technician"); + + var statsAct = () => service.GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var statsException = await statsAct.Should().ThrowAsync(); + statsException.Which.Code.Should().Be("Forbidden"); + + var workloadAct = () => service.GetWorkloadAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var workloadException = await workloadAct.Should().ThrowAsync(); + workloadException.Which.Code.Should().Be("Forbidden"); + + var performanceAct = () => service.GetPerformanceAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var performanceException = await performanceAct.Should().ThrowAsync(); + performanceException.Which.Code.Should().Be("Forbidden"); + + var regionsAct = () => service.GetRegionsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var regionsException = await regionsAct.Should().ThrowAsync(); + regionsException.Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.AddRange( + new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }, + new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" }); + ctx.UserRoles.AddRange( + new IdentityUserRole { UserId = "dispatcher-1", RoleId = "dispatcher-role" }, + new IdentityUserRole { UserId = "dispatcher-2", RoleId = "dispatcher-role" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }); + ctx.SaveChanges(); + + var workload = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + workload.TotalDispatchers.Should().Be(2); + workload.Items.Should().HaveCount(2); + workload.Items[0].DispatcherId.Should().Be("dispatcher-1"); + workload.Items[0].TotalCount.Should().Be(2); + workload.Items[0].OpenCount.Should().Be(2); + workload.Items[1].DispatcherId.Should().Be("dispatcher-2"); + workload.Items[1].TotalCount.Should().Be(0); + workload.Items[1].OpenCount.Should().Be(0); + workload.Page.Should().Be(1); + workload.PageSize.Should().Be(10); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items.Should().HaveCount(2); + performance.Items[0].DispatcherId.Should().Be("dispatcher-1"); + performance.Items[0].AssignedCount.Should().Be(2); + performance.Items[1].DispatcherId.Should().Be("dispatcher-2"); + performance.Items[1].AssignedCount.Should().Be(0); + performance.Items[1].CompletedCount.Should().Be(0); + } + + [Fact] + public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].AssignedCount.Should().Be(3); + result.Items[0].CompletedCount.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Unparseable" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().HaveCount(4); + stats.StatusDistribution + .Select(bucket => (bucket.Status, bucket.Count)) + .Should().BeEquivalentTo(new[] + { + (Status: "Completed", Count: 2), + (Status: "In Progress", Count: 1), + (Status: "Scheduled", Count: 1), + (Status: "Unknown", Count: 1) + }); + stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders() + { + using var ctx = NewContext(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().BeEmpty(); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 055b933..cc63f77 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -34,7 +34,8 @@ namespace Api.SeaHavenIndustries.Controllers dueCount = stats.DueCount, completedDueCount = stats.CompletedDueCount, completionRate = stats.CompletionRate, - averageResolutionDays = stats.AverageResolutionDays + averageResolutionDays = stats.AverageResolutionDays, + statusDistribution = stats.StatusDistribution }); } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index a56f74f..f92d080 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -102,7 +102,6 @@ namespace SeaHaven.DataServices.Implementation on userRole.RoleId equals role.Id where user.IsDeleted != true && (role.Name == "Dispatcher" || role.Name == "DISPATCHER") - && (dispatcherId == null || user.Id == dispatcherId) select new { user.Id, @@ -114,6 +113,9 @@ namespace SeaHaven.DataServices.Implementation if (accountId is int scopedAccountId) workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); + if (dispatcherId != null) + workOrders = workOrders.Where(w => w.AssignTo == dispatcherId); + if (dateFrom is DateOnly from && dateTo is DateOnly to) { var fromDate = from.ToDateTime(TimeOnly.MinValue); diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index a767a03..9c9d3a7 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs { public DateOnly? DateFrom { get; set; } public DateOnly? DateTo { get; set; } + public string? DispatcherId { get; set; } } public sealed class DashboardWorkloadResponseDTO @@ -38,6 +39,8 @@ namespace SeaHaven.Services.DTOs public string? Color { get; init; } public decimal CompletionRate { get; init; } public decimal? AverageResolutionDays { get; init; } + public int AssignedCount { get; init; } + public int CompletedCount { get; init; } } public sealed class DashboardRegionResponseDTO @@ -80,6 +83,13 @@ namespace SeaHaven.Services.DTOs public int CompletedDueCount { get; set; } public decimal CompletionRate { get; set; } public decimal? AverageResolutionDays { get; set; } + public IReadOnlyList StatusDistribution { get; set; } = []; + } + + public sealed class DashboardStatusBucketDTO + { + public string Status { get; init; } = string.Empty; + public int Count { get; init; } } public sealed class DashboardBreakdownDTO diff --git a/SeaHaven.Services/Helpers/DashboardMetrics.cs b/SeaHaven.Services/Helpers/DashboardMetrics.cs index e49d921..885ac7d 100644 --- a/SeaHaven.Services/Helpers/DashboardMetrics.cs +++ b/SeaHaven.Services/Helpers/DashboardMetrics.cs @@ -61,9 +61,26 @@ public static class DashboardMetrics dueCount, completedDueCount, dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2), - resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2)); + resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2), + BuildStatusDistribution(workOrders)); } + private static IReadOnlyList BuildStatusDistribution( + IReadOnlyList workOrders) + => workOrders + .GroupBy(workOrder => workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus)) + .Select(group => new DashboardStatusBucketDTO + { + Status = group.Key.HasValue + ? LifecycleStatusMapper.ToFeLabel(group.Key.Value) + : "Unknown", + Count = group.Count() + }) + .OrderByDescending(bucket => bucket.Count) + .ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase) + .ToList(); + private static bool IsPastDue( DashboardWorkOrder workOrder, LifecycleStatus? status, @@ -110,4 +127,5 @@ public sealed record DashboardMetricResult( int DueCount, int CompletedDueCount, decimal CompletionRate, - decimal? AverageResolutionDays); + decimal? AverageResolutionDays, + IReadOnlyList StatusDistribution); diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 757dfb3..49c5c1f 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -26,11 +26,7 @@ namespace SeaHaven.Services.Implementation CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); var workOrders = await _dataService.GetDashboardWorkOrdersAsync( @@ -53,7 +49,8 @@ namespace SeaHaven.Services.Implementation DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, CompletionRate = metrics.CompletionRate, - AverageResolutionDays = metrics.AverageResolutionDays + AverageResolutionDays = metrics.AverageResolutionDays, + StatusDistribution = metrics.StatusDistribution }; } @@ -67,11 +64,7 @@ namespace SeaHaven.Services.Implementation throw new ArgumentOutOfRangeException(nameof(page)); var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var rows = await _dataService.GetDispatcherWorkloadAsync( accountId, @@ -113,11 +106,7 @@ namespace SeaHaven.Services.Implementation throw new ArgumentOutOfRangeException(nameof(page)); var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var directory = await _dataService.GetDispatcherWorkloadAsync( accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); @@ -135,7 +124,9 @@ namespace SeaHaven.Services.Implementation DispatcherName = dispatcher.DispatcherName, Color = dispatcher.Color, CompletionRate = metrics.CompletionRate, - AverageResolutionDays = metrics.AverageResolutionDays + AverageResolutionDays = metrics.AverageResolutionDays, + AssignedCount = metrics.Total, + CompletedCount = metrics.Completed }; }) .OrderByDescending(row => row.CompletionRate) @@ -159,11 +150,7 @@ namespace SeaHaven.Services.Implementation CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var workOrders = await _dataService.GetDashboardWorkOrdersAsync( accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); @@ -228,5 +215,33 @@ namespace SeaHaven.Services.Implementation TotalVendors = rows.Count }; } + + private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query) + { + if (user.IsInRole("Dispatcher")) + { + var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + return dispatcherId; + } + + if (user.IsInRole("Admin") || user.IsInRole("Manager")) + { + var selected = query.DispatcherId; + if (string.IsNullOrWhiteSpace(selected)) + return null; + if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase)) + { + var identity = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(identity)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + return identity; + } + return selected; + } + + throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role."); + } } } From 60e006e8d7ab8e04cdf0adac2b5c434580741a0b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:17:59 -0300 Subject: [PATCH 13/22] fix(uplifts): calculate granted exposure amounts (SH-207) --- .../UpliftQueueReadTests.cs | 33 ++++++++++++++++++- .../Implementation/UpliftDataService.cs | 10 ++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index ad539bd..09beca4 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -86,7 +86,8 @@ public sealed class UpliftQueueReadTests var vendor = new Vendor { CompanyName = vendorName, IsActive = true }; var workOrder = new WorkOrder { - WorkerOrderNumber = number, + InternalWONumber = number, + WorkerOrderNumber = $"legacy-{number}", SiteCode = site, Service = service, WorkerOrderTitle = "Repair" @@ -407,6 +408,36 @@ public sealed class UpliftQueueReadTests itemB.WorkOrderApprovedExposureTotal.Should().Be(75m); } + [Fact] + public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN"); + context.DispatchUpliftRequests.AddRange( + Request( + dispatch, + "Approved", + new DateTime(2026, 3, 1), + decided: new DateTime(2026, 3, 2), + requested: 400m, + currentNte: 100m), + Request( + dispatch, + "Approved", + new DateTime(2026, 3, 3), + decided: new DateTime(2026, 3, 4), + requested: 250m, + createdBy: "internal-user", + currentNte: 100m)); + await context.SaveChangesAsync(); + + var exposure = await new UpliftDataService(context) + .GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None); + + exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m); + } + [Fact] public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks() { diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 8d7ac66..658c8a7 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -61,7 +61,7 @@ namespace SeaHaven.DataServices.Implementation PONumber = x.d.PONumber, WorkOrderId = x.d.WorkOrderId, VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, - WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null, + WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null, WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null, WorkOrderService = x.wo != null ? x.wo.Service : null, RequestedByVendorName = x.u.RequestedByVendorName, @@ -317,9 +317,13 @@ namespace SeaHaven.DataServices.Implementation { DispatchId = g.Key, AutoApproved = g.Where(x => x.Status == "NoApprovalRequired") - .Sum(x => (decimal?)x.RequestedNTE), + .Sum(x => (decimal?)(x.createdby == null + ? x.RequestedNTE - (x.CurrentNTE ?? 0m) + : x.RequestedNTE)), AdminApproved = g.Where(x => x.Status == "Approved") - .Sum(x => (decimal?)x.RequestedNTE) + .Sum(x => (decimal?)(x.createdby == null + ? x.RequestedNTE - (x.CurrentNTE ?? 0m) + : x.RequestedNTE)) }) .ToListAsync(cancellationToken); From 316d1e6a6390ea52042633f41d9a114b2e6fcd4c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:19:43 -0300 Subject: [PATCH 14/22] fix(uplifts): correct queue exposure projections (SH-210) --- .../UpliftQueueReadTests.cs | 62 ++++++++++++++++++- .../Implementation/UpliftDataService.cs | 16 ++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index ad539bd..39d8d91 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -86,6 +86,7 @@ public sealed class UpliftQueueReadTests var vendor = new Vendor { CompanyName = vendorName, IsActive = true }; var workOrder = new WorkOrder { + InternalWONumber = number, WorkerOrderNumber = number, SiteCode = site, Service = service, @@ -280,6 +281,35 @@ public sealed class UpliftQueueReadTests item.Status.Should().Be("Pending"); } + [Fact] + public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + // Synced work orders carry the CRM external id in WorkerOrderNumber; the SH + // display number the board renders is stamped on InternalWONumber. + var workOrder = new WorkOrder + { + InternalWONumber = "10000000001", + WorkerOrderNumber = "CRM-EXT-77", + SiteCode = "SITE-EAST", + Service = "HVAC", + WorkerOrderTitle = "Repair" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderNumber.Should().Be("10000000001"); + item.WorkOrderNumber.Should().NotBe("CRM-EXT-77"); + } + [Fact] public async Task List_RequesterLabelFallsBackToCreatingUserName() { @@ -353,7 +383,9 @@ public sealed class UpliftQueueReadTests var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); context.DispatchUpliftRequests.AddRange( - Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m), + // Vendor-portal row (createdby null): stores the requested new NTE total, + // so the granted amount is 400 - 100 = 300. + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m), new DispatchUpliftRequest { DispatchId = dispatchA.Id, @@ -390,7 +422,7 @@ public sealed class UpliftQueueReadTests RequiredTier = 1, RequestedNTE = 999m }, - Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m)); + Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m)); await context.SaveChangesAsync(); var service = NewService(context); @@ -440,6 +472,32 @@ public sealed class UpliftQueueReadTests total.AutoApprovedTotal.Should().Be(0m); } + [Fact] + public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total: + // 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300. + Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m), + // Work-order-path rows (createdby set) store the granted increment: 200. + Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7")); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrder.Id }, CancellationToken.None); + + // Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would + // double-count whole NTE totals and report 3500. + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject; + total.AdminApprovedTotal.Should().Be(1000m); + total.AutoApprovedTotal.Should().Be(0m); + } + // --- Read authorization (tier roles) --- [Fact] diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 6c52434..5cb7013 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -61,7 +61,10 @@ namespace SeaHaven.DataServices.Implementation PONumber = x.d.PONumber, WorkOrderId = x.d.WorkOrderId, VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, - WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null, + // The queue renders the internal display number (InternalWONumber, what the + // board shows); WorkerOrderNumber holds the CRM external id on synced + // work orders and must not leak into the queue display. + WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null, WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null, WorkOrderService = x.wo != null ? x.wo.Service : null, RequestedByVendorName = x.u.RequestedByVendorName, @@ -339,8 +342,17 @@ namespace SeaHaven.DataServices.Implementation DispatchId = g.Key, AutoApproved = g.Where(x => x.Status == "NoApprovalRequired") .Sum(x => (decimal?)x.RequestedNTE), + // The two creation paths store different meanings in RequestedNTE. + // Vendor-portal rows store the requested new NTE total, so the + // granted amount is RequestedNTE - CurrentNTE; work-order-path rows + // store the granted increment directly. Vendor sessions have no + // identity user, so createdby is null only on vendor-portal rows. Summing + // granted amounts keeps sequential approvals from double-counting whole + // NTE totals. AdminApproved = g.Where(x => x.Status == "Approved") - .Sum(x => (decimal?)x.RequestedNTE) + .Sum(x => (decimal?)(x.createdby == null + ? x.RequestedNTE - (x.CurrentNTE ?? 0m) + : x.RequestedNTE)) }) .ToListAsync(cancellationToken); From b3e9a2eec3f8aa350162200d49f7217568930214 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:30:32 -0300 Subject: [PATCH 15/22] fix(services): allow admins to reactivate services --- .../ServicesRegistryServiceTests.cs | 32 +++++++++++++++++++ .../DTOs/ServicesRegistryDTOs.cs | 1 + .../Implementation/ServicesRegistryService.cs | 11 +++++++ 3 files changed, 44 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs index 6d7086d..9eba754 100644 --- a/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs @@ -260,6 +260,38 @@ public class ServicesRegistryServiceTests updated.IsActive.Should().BeFalse("update preserves the persisted active state"); } + [Fact] + public async Task Update_WhenAdminRequestsActiveState_ReactivatesService() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var input = Input("Inactive service"); + input.IsActive = true; + var updated = await service.UpdateAsync(Admin(), created.Id, input, CancellationToken.None); + + updated.IsActive.Should().BeTrue(); + ctx.Services.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Update_WhenSchedulerRequestsActiveState_ThrowsForbidden() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var input = Input("Inactive service"); + input.IsActive = true; + var act = () => service.UpdateAsync(Scheduler(), created.Id, input, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().IsActive.Should().BeFalse(); + } + [Fact] public async Task Update_WhenNotFound_ThrowsNotFound() { diff --git a/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs b/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs index 70fe43b..acb1bb4 100644 --- a/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs +++ b/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs @@ -28,6 +28,7 @@ namespace SeaHaven.Services.DTOs public bool RequiresCompletionDocument { get; set; } public int? CompletionDocTemplateId { get; set; } public List? SupportedWorkOrderTypes { get; set; } + public bool? IsActive { get; set; } } public class ServicesRegistryValidationErrorDto diff --git a/SeaHaven.Services/Implementation/ServicesRegistryService.cs b/SeaHaven.Services/Implementation/ServicesRegistryService.cs index b6c7dd0..1a702bb 100644 --- a/SeaHaven.Services/Implementation/ServicesRegistryService.cs +++ b/SeaHaven.Services/Implementation/ServicesRegistryService.cs @@ -108,6 +108,11 @@ namespace SeaHaven.Services.Implementation service.IconKey = iconKey; service.RequiresCompletionDocument = input.RequiresCompletionDocument; service.CompletionDocTemplateId = template?.Id; + if (input.IsActive.HasValue) + { + EnsureCanChangeActiveState(user); + service.IsActive = input.IsActive.Value; + } try { @@ -138,6 +143,12 @@ namespace SeaHaven.Services.Implementation throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."); } + private static void EnsureCanChangeActiveState(ClaimsPrincipal user) + { + if (user is null || !user.IsInRole(AdminRole)) + throw new ServicesRegistryValidationException("Forbidden", "Only administrators can change service active state."); + } + private static ServicesRegistryValidationException DuplicateName() => new("DuplicateName", "A service with this name already exists."); From 65b04687bcba801ae9015c1ae6912e4d69d25986 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:30:32 -0300 Subject: [PATCH 16/22] fix(work-orders): honor edited service labels --- .../Implementation/WorkOrderBoardService.cs | 2 +- .../WorkOrderBoardServiceTests.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 085e3db..7a7f1b7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -138,7 +138,7 @@ namespace SeaHaven.Services.Implementation TechPhone = row.TechPhone, ApptDate = row.DispatchApptDate ?? row.ScheduledDate, ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd), - Pm = row.ServiceNameSnapshot ?? row.Trade ?? row.Problem, + Pm = row.Trade ?? row.ServiceNameSnapshot ?? row.Problem, ServiceNotes = row.ServiceNotes, ExtraServices = ParseExtraServices(row.ExtraServices), AdditionalContacts = MapAdditionalContacts(row), diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 9e35213..96acd1b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -1043,6 +1043,24 @@ public class WorkOrderBoardServiceTests Assert.Equal("09:00", dto.ApptTime); } + [Fact] + public void MapRawRow_PmEditTakesPrecedenceOverServiceSnapshot() + { + var row = RawRow( + scheduledDate: new DateTime(2026, 6, 23), + scheduledStart: null, + scheduledEnd: null, + dispatchApptDate: null) with + { + Trade = "Updated service label", + ServiceNameSnapshot = "Original service label" + }; + + var dto = WorkOrderBoardService.MapRawRow(row, DateTime.UtcNow); + + Assert.Equal("Updated service label", dto.Pm); + } + private static WorkOrderBoardRawRow RawRow( DateTime? scheduledDate, DateTime? scheduledStart, From e0e45d5ed35e45354d4f53f60f3b731b9bccabae Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:32:23 -0300 Subject: [PATCH 17/22] feat(uplifts): expose approval queue contract fields (SH-208) --- .../Models/VendorPortalReadModels.cs | 4 +++ .../Implementation/UpliftDataService.cs | 25 +++++++++++++++++-- .../Interfaces/IUpliftDataService.cs | 1 + SeaHaven.Services/DTOs/UpliftDTOs.cs | 4 +++ .../Implementation/UpliftService.cs | 7 +++++- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 05ea0ab..12c4781 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -88,6 +88,8 @@ namespace Data.SeaHavenIndustries public string? RequestedByVendorName { get; set; } public string? RequestedByFirstName { get; set; } public string? RequestedByLastName { get; set; } + public string? DecidedByFirstName { get; set; } + public string? DecidedByLastName { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } public string? VendorReason { get; set; } @@ -104,6 +106,8 @@ namespace Data.SeaHavenIndustries public DateTime? ExpiresAt { get; set; } public string? NotificationStatus { get; set; } public string? NotificationError { get; set; } + public int AttachmentCount { get; set; } + public bool WorkOrderClosed { get; set; } } public class UpliftForWorkOrderData : UpliftForDispatchData diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 658c8a7..3bb7297 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; @@ -28,9 +29,11 @@ namespace SeaHaven.DataServices.Implementation from wo in workOrders.DefaultIfEmpty() join reqUser in _context.Users on u.createdby equals reqUser.Id into requestUsers from reqUser in requestUsers.DefaultIfEmpty() + join decUser in _context.Users on u.DecidedByUserId equals decUser.Id into decisionUsers + from decUser in decisionUsers.DefaultIfEmpty() where (u.IsDeleted == null || u.IsDeleted == false) && (d.IsDeleted == null || d.IsDeleted == false) - select new { u, d, v, ev, wo, reqUser }; + select new { u, d, v, ev, wo, reqUser, decUser }; if (!string.IsNullOrWhiteSpace(status)) query = query.Where(x => x.u.Status == status); @@ -67,6 +70,8 @@ namespace SeaHaven.DataServices.Implementation RequestedByVendorName = x.u.RequestedByVendorName, RequestedByFirstName = x.reqUser != null ? x.reqUser.FirstName : null, RequestedByLastName = x.reqUser != null ? x.reqUser.LastName : null, + DecidedByFirstName = x.decUser != null ? x.decUser.FirstName : null, + DecidedByLastName = x.decUser != null ? x.decUser.LastName : null, CurrentNTE = x.u.CurrentNTE, RequestedNTE = x.u.RequestedNTE, VendorReason = x.u.VendorReason, @@ -81,13 +86,29 @@ namespace SeaHaven.DataServices.Implementation EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null, ExpiresAt = x.u.ExpiresAt, NotificationStatus = x.u.NotificationStatus, - NotificationError = x.u.NotificationError + NotificationError = x.u.NotificationError, + AttachmentCount = _context.VendorCompletionDocuments.Count(document => + document.DispatchId == x.d.Id + && (document.IsDeleted == null || document.IsDeleted == false)), + WorkOrderClosed = x.wo != null + && (x.wo.LifecycleStatus == LifecycleStatus.Completed + || x.wo.LifecycleStatus == LifecycleStatus.Canceled) }) .ToListAsync(cancellationToken); return (total, items); } + public Task GetPendingExposureAsync(CancellationToken cancellationToken) + { + return _context.DispatchUpliftRequests + .Where(u => (u.IsDeleted == null || u.IsDeleted == false) + && u.Status == "Pending") + .SumAsync(u => u.createdby == null + ? u.RequestedNTE - (u.CurrentNTE ?? 0m) + : u.RequestedNTE, cancellationToken); + } + public async Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return await (from u in _context.DispatchUpliftRequests diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 15be0ab..3133657 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -6,6 +6,7 @@ namespace SeaHaven.DataServices.Interfaces { Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); + Task GetPendingExposureAsync(CancellationToken cancellationToken); Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs index 5333f07..b2b8544 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -13,6 +13,7 @@ namespace SeaHaven.Services.DTOs public string? WorkOrderSite { get; set; } public string? WorkOrderService { get; set; } public string? RequestedByName { get; set; } + public string? DecidedByName { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } public decimal Delta { get; set; } @@ -31,6 +32,8 @@ namespace SeaHaven.Services.DTOs public DateTime? ExpiresAt { get; set; } public string? NotificationStatus { get; set; } public string? NotificationError { get; set; } + public int AttachmentCount { get; set; } + public bool WorkOrderClosed { get; set; } // Approved-on-WO exposure totals for this item's work order: auto-approved and // admin-approved amounts; pending, rejected, cancelled/withdrawn, and revoked // requests are excluded. @@ -44,6 +47,7 @@ namespace SeaHaven.Services.DTOs public int Total { get; set; } public int Page { get; set; } public int PageSize { get; set; } + public decimal PendingExposureTotal { get; set; } public IEnumerable Items { get; set; } = Enumerable.Empty(); } diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 034f102..cfc2615 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -33,6 +33,7 @@ namespace SeaHaven.Services.Implementation public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) { var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken); + var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken); var mapped = items.Select(r => new UpliftListItemDTO { @@ -46,6 +47,7 @@ namespace SeaHaven.Services.Implementation WorkOrderSite = r.WorkOrderSiteCode, WorkOrderService = r.WorkOrderService, RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName), + DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName), CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), @@ -62,7 +64,9 @@ namespace SeaHaven.Services.Implementation EvidenceSizeBytes = r.EvidenceSizeBytes, ExpiresAt = r.ExpiresAt, NotificationStatus = r.NotificationStatus, - NotificationError = r.NotificationError + NotificationError = r.NotificationError, + AttachmentCount = r.AttachmentCount, + WorkOrderClosed = r.WorkOrderClosed }).ToList(); // Approved-on-WO exposure totals are aggregated once for the whole page @@ -90,6 +94,7 @@ namespace SeaHaven.Services.Implementation Total = total, Page = page, PageSize = pageSize, + PendingExposureTotal = pendingExposureTotal, Items = mapped }; } From b5e8f038c1ff984c14c2b3921e54c1b778a67400 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:38:23 -0300 Subject: [PATCH 18/22] test(uplifts): cover queue contract fields (SH-208) Behavior coverage for the new queue read fields so the contract cannot regress silently: workOrderClosed follows terminal lifecycle statuses, decidedByName resolves from the deciding user, attachmentCount counts active dispatch documents, and pendingExposureTotal sums granted amounts across all pending requests independent of paging. --- .../UpliftQueueReadTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 09beca4..89ef430 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -343,6 +344,125 @@ public sealed class UpliftQueueReadTests item.EvidenceSizeBytes.Should().Be(512); } + // --- Queue contract fields (SH-208/SH-213) --- + + [Fact] + public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing"); + workOrderA.LifecycleStatus = LifecycleStatus.Completed; + workOrderB.LifecycleStatus = LifecycleStatus.Canceled; + await context.SaveChangesAsync(); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical"); + var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatchB, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4)), + Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue(); + result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue(); + result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse(); + } + + [Fact] + public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Approved", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + DecidedByUserId = "user-9", + RequiredTier = 1, + RequestedNTE = 300m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); + } + + [Fact] + public async Task List_AttachmentCount_CountsActiveDocumentsOnTheDispatch() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.VendorCompletionDocuments.AddRange( + new VendorCompletionDocument + { + VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, + OriginalFileName = "quote.pdf", StoredFileName = "a.bin", ContentType = "application/pdf", + SizeBytes = 512, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "UpliftEvidence", Version = 1 + }, + new VendorCompletionDocument + { + VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, + OriginalFileName = "photo.jpg", StoredFileName = "b.bin", ContentType = "image/jpeg", + SizeBytes = 2048, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "Completion", Version = 1 + }, + new VendorCompletionDocument + { + VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, + OriginalFileName = "old.pdf", StoredFileName = "c.bin", ContentType = "application/pdf", + SizeBytes = 128, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "Completion", Version = 1, + IsDeleted = true + }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Single().AttachmentCount.Should().Be(2); + } + + [Fact] + public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total, + // so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350. + Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m), + // Work-order-path rows (createdby set) store the requested increment: 90. + Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"), + // Non-pending rows never add pending exposure. + Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + // Page 1 with a single row still reports the total across ALL pending rows. + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.PendingExposureTotal.Should().Be(740m); + } + // --- Approved-on-WO exposure totals --- [Fact] From 1b73593189700a5fb4c2c7d5cbb19ecce049c4f5 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:43:51 -0300 Subject: [PATCH 19/22] style(uplifts): format queue contract tests (SH-208) --- .../UpliftQueueReadTests.cs | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 89ef430..46ea048 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -409,21 +409,45 @@ public sealed class UpliftQueueReadTests context.VendorCompletionDocuments.AddRange( new VendorCompletionDocument { - VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, - OriginalFileName = "quote.pdf", StoredFileName = "a.bin", ContentType = "application/pdf", - SizeBytes = 512, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "UpliftEvidence", Version = 1 + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "quote.pdf", + StoredFileName = "a.bin", + ContentType = "application/pdf", + SizeBytes = 512, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 1 }, new VendorCompletionDocument { - VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, - OriginalFileName = "photo.jpg", StoredFileName = "b.bin", ContentType = "image/jpeg", - SizeBytes = 2048, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "Completion", Version = 1 + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "photo.jpg", + StoredFileName = "b.bin", + ContentType = "image/jpeg", + SizeBytes = 2048, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "Completion", + Version = 1 }, new VendorCompletionDocument { - VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, - OriginalFileName = "old.pdf", StoredFileName = "c.bin", ContentType = "application/pdf", - SizeBytes = 128, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "Completion", Version = 1, + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "old.pdf", + StoredFileName = "c.bin", + ContentType = "application/pdf", + SizeBytes = 128, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "Completion", + Version = 1, IsDeleted = true }); await context.SaveChangesAsync(); From fbae09136b8a1a9de98c22d26d5f42d21a32253a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:48:07 -0300 Subject: [PATCH 20/22] fix(uplifts): update test data service contract (SH-208) --- SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 12ddd97..6cc2fd0 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -431,6 +431,7 @@ public class WorkOrderBoardCancelServiceTests public Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetPendingExposureAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); From 146fe6fdb016f844989e76f827d596fa6c9c905e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 17 Sep 2026 02:26:19 -0300 Subject: [PATCH 21/22] style: satisfy dotnet format whitespace gate for region board files Break collection initializer entries onto one line each in WorkOrderBoardRegions and WorkOrderBoardSearchTests so the changed-file formatting gate (dotnet format --verify-no-changes) passes. --- .../Helpers/WorkOrderBoardRegions.cs | 63 +++++++++++++++---- .../WorkOrderBoardSearchTests.cs | 3 +- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs index 0ca39ec..17a2d5e 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs @@ -17,19 +17,56 @@ namespace SeaHaven.DataServices.Helpers private static readonly IReadOnlyDictionary StateNames = new Dictionary(StringComparer.Ordinal) { - ["AL"] = "ALABAMA", ["AK"] = "ALASKA", ["AZ"] = "ARIZONA", ["AR"] = "ARKANSAS", - ["CA"] = "CALIFORNIA", ["CO"] = "COLORADO", ["CT"] = "CONNECTICUT", ["DE"] = "DELAWARE", - ["FL"] = "FLORIDA", ["GA"] = "GEORGIA", ["HI"] = "HAWAII", ["ID"] = "IDAHO", - ["IL"] = "ILLINOIS", ["IN"] = "INDIANA", ["IA"] = "IOWA", ["KS"] = "KANSAS", - ["KY"] = "KENTUCKY", ["LA"] = "LOUISIANA", ["ME"] = "MAINE", ["MD"] = "MARYLAND", - ["MA"] = "MASSACHUSETTS", ["MI"] = "MICHIGAN", ["MN"] = "MINNESOTA", ["MS"] = "MISSISSIPPI", - ["MO"] = "MISSOURI", ["MT"] = "MONTANA", ["NE"] = "NEBRASKA", ["NV"] = "NEVADA", - ["NH"] = "NEW HAMPSHIRE", ["NJ"] = "NEW JERSEY", ["NM"] = "NEW MEXICO", ["NY"] = "NEW YORK", - ["NC"] = "NORTH CAROLINA", ["ND"] = "NORTH DAKOTA", ["OH"] = "OHIO", ["OK"] = "OKLAHOMA", - ["OR"] = "OREGON", ["PA"] = "PENNSYLVANIA", ["RI"] = "RHODE ISLAND", ["SC"] = "SOUTH CAROLINA", - ["SD"] = "SOUTH DAKOTA", ["TN"] = "TENNESSEE", ["TX"] = "TEXAS", ["UT"] = "UTAH", - ["VT"] = "VERMONT", ["VA"] = "VIRGINIA", ["WA"] = "WASHINGTON", ["WV"] = "WEST VIRGINIA", - ["WI"] = "WISCONSIN", ["WY"] = "WYOMING" + ["AL"] = "ALABAMA", + ["AK"] = "ALASKA", + ["AZ"] = "ARIZONA", + ["AR"] = "ARKANSAS", + ["CA"] = "CALIFORNIA", + ["CO"] = "COLORADO", + ["CT"] = "CONNECTICUT", + ["DE"] = "DELAWARE", + ["FL"] = "FLORIDA", + ["GA"] = "GEORGIA", + ["HI"] = "HAWAII", + ["ID"] = "IDAHO", + ["IL"] = "ILLINOIS", + ["IN"] = "INDIANA", + ["IA"] = "IOWA", + ["KS"] = "KANSAS", + ["KY"] = "KENTUCKY", + ["LA"] = "LOUISIANA", + ["ME"] = "MAINE", + ["MD"] = "MARYLAND", + ["MA"] = "MASSACHUSETTS", + ["MI"] = "MICHIGAN", + ["MN"] = "MINNESOTA", + ["MS"] = "MISSISSIPPI", + ["MO"] = "MISSOURI", + ["MT"] = "MONTANA", + ["NE"] = "NEBRASKA", + ["NV"] = "NEVADA", + ["NH"] = "NEW HAMPSHIRE", + ["NJ"] = "NEW JERSEY", + ["NM"] = "NEW MEXICO", + ["NY"] = "NEW YORK", + ["NC"] = "NORTH CAROLINA", + ["ND"] = "NORTH DAKOTA", + ["OH"] = "OHIO", + ["OK"] = "OKLAHOMA", + ["OR"] = "OREGON", + ["PA"] = "PENNSYLVANIA", + ["RI"] = "RHODE ISLAND", + ["SC"] = "SOUTH CAROLINA", + ["SD"] = "SOUTH DAKOTA", + ["TN"] = "TENNESSEE", + ["TX"] = "TEXAS", + ["UT"] = "UTAH", + ["VT"] = "VERMONT", + ["VA"] = "VIRGINIA", + ["WA"] = "WASHINGTON", + ["WV"] = "WEST VIRGINIA", + ["WI"] = "WISCONSIN", + ["WY"] = "WYOMING" }; public static IReadOnlyCollection StorageValuesFor(string region) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 033d3fb..cd47e05 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -251,7 +251,8 @@ public class WorkOrderAdvancedSearchServiceTests var query = new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, - DateFrom = new DateOnly(2026, 6, 22), DateTo = new DateOnly(2026, 6, 30), + DateFrom = new DateOnly(2026, 6, 22), + DateTo = new DateOnly(2026, 6, 30), PageSize = 50 }; From 564bd70301d57b100291223c6157dadcb6c35118 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 17 Sep 2026 03:37:36 -0300 Subject: [PATCH 22/22] fix(dashboard): scope Trend by picker and admit Scheduler to dispatcher picker ResolveDispatcherScope now admits the Scheduler role, which owns the viewAllDispatchersOnDashboard permission, so it can load Stats, Workload, Performance, Regions and Trend instead of being denied. GetTrendAsync now resolves scope via the shared ResolveDispatcherScope so a picker DispatcherId selection scopes Trend consistently with the other endpoints and unauthorized roles fail closed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DashboardServiceTests.cs | 22 +++++++++++++++++++ .../Implementation/DashboardService.cs | 8 ++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index b1bb099..2cc73ee 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -517,6 +517,23 @@ public class DashboardServiceTests stats.Total.Should().Be(1); } + [Fact] + public async Task GetStatsAsync_SchedulerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "scheduler-1", "Scheduler"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + [Fact] public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed() { @@ -543,6 +560,11 @@ public class DashboardServiceTests user, new DashboardStatsQueryDTO(), CancellationToken.None); var regionsException = await regionsAct.Should().ThrowAsync(); regionsException.Which.Code.Should().Be("Forbidden"); + + var trendAct = () => service.GetTrendAsync( + user, new DashboardTrendQueryDTO(), CancellationToken.None); + var trendException = await trendAct.Should().ThrowAsync(); + trendException.Which.Code.Should().Be("Forbidden"); } [Fact] diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 68c4a5f..02642dc 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -183,11 +183,7 @@ namespace SeaHaven.Services.Implementation CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var today = DashboardBusinessTime.Today(); var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); @@ -402,7 +398,7 @@ namespace SeaHaven.Services.Implementation return dispatcherId; } - if (user.IsInRole("Admin") || user.IsInRole("Manager")) + if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler")) { var selected = query.DispatcherId; if (string.IsNullOrWhiteSpace(selected))