From 06f9450485c2aa10a3068fd3d295f86f782bed0e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 16:18:46 -0300 Subject: [PATCH 01/28] 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 efd13b6f60bfc7834371c8880c481df77e87aad6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:12:03 -0300 Subject: [PATCH 02/28] fix(dashboard): scope stats to authenticated accounts (SH-336) --- .../DashboardServiceTests.cs | 86 ++++++++++++++++--- .../Controllers/DashboardController.cs | 2 +- .../Implementation/DashboardDataService.cs | 8 +- .../Interfaces/IDashboardDataService.cs | 4 +- .../Implementation/DashboardService.cs | 14 ++- .../Interfaces/IDashboardService.cs | 5 +- 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 0af8f00..c45ab1a 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -1,7 +1,10 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; @@ -17,33 +20,90 @@ public class DashboardServiceTests return new ApplicationDbContext(options); } - private static DashboardService NewService(ApplicationDbContext ctx) => - new(new DashboardDataService(ctx)); + private static DashboardService NewService(ApplicationDbContext ctx) + { + var resolver = new WorkOrderAccountResolver( + new AccountDataService(ctx), + new LocationDataService(ctx)); + return new DashboardService(new DashboardDataService(ctx), resolver); + } - private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) => - new() { Status = status, istemplate = isTemplate, AssignTo = assignTo }; + private static ClaimsPrincipal AccountUser(int accountId) + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.Role, "Dispatcher") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static ClaimsPrincipal OrgWideUser() + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + new Claim(ClaimTypes.Role, "Admin") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } [Fact] public async Task GetStatsAsync_CountsByStatusExcludingTemplates() { using var ctx = NewContext(); ctx.workOrders.AddRange( - Wo("Open", isTemplate: false), - Wo("Open", isTemplate: false, assignTo: "u1"), - Wo("In Progress"), - Wo("On Hold"), - Wo("Done"), - Wo("Done"), - Wo("Open", isTemplate: true), - Wo("Cancelled", isTemplate: false) + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "On Hold" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = true }, + new WorkOrder { AccountId = 1, Status = "Cancelled" }, + new WorkOrder { AccountId = 2, Status = "Open" }, + new WorkOrder { Status = "Open" } ); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync(AccountUser(1), CancellationToken.None); stats.Total.Should().Be(7); stats.Open.Should().Be(4); stats.NotDispatched.Should().Be(1); stats.Completed.Should().Be(2); } + + [Fact] + public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open" }, + new WorkOrder { AccountId = 2, Status = "Done" }, + new WorkOrder { Status = "Open", istemplate = true }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync(OrgWideUser(), CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(1); + stats.Completed.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_MissingScopeFailsClosed() + { + using var ctx = NewContext(); + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Dispatcher") + }, "test")); + + var act = () => NewService(ctx).GetStatsAsync(user, CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + + exception.Which.Code.Should().Be("Forbidden"); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 6955cc2..4841c82 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -19,7 +19,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("Stats")] public async Task GetStats(CancellationToken cancellationToken) { - var stats = await _dashboardService.GetStatsAsync(cancellationToken); + var stats = await _dashboardService.GetStatsAsync(User, cancellationToken); return Ok(new { diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index d1e29c3..8bf21c9 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -13,9 +13,15 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public async Task GetWorkOrderCountsAsync(CancellationToken cancellationToken) + public async Task GetWorkOrderCountsAsync( + int? accountId, + CancellationToken cancellationToken) { var query = _context.workOrders.Where(w => w.istemplate != true); + if (accountId is int scopedAccountId) + { + query = query.Where(w => w.AccountId == scopedAccountId); + } var total = await query.CountAsync(cancellationToken); var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 7acb41f..5993609 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -2,7 +2,9 @@ namespace SeaHaven.DataServices.Interfaces { public interface IDashboardDataService { - Task GetWorkOrderCountsAsync(CancellationToken cancellationToken); + Task GetWorkOrderCountsAsync( + int? accountId, + CancellationToken cancellationToken); } public class DashboardCounts diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 0bec513..30695b9 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -7,15 +8,22 @@ namespace SeaHaven.Services.Implementation public class DashboardService : IDashboardService { private readonly IDashboardDataService _dataService; + private readonly IWorkOrderAccountResolver _accountResolver; - public DashboardService(IDashboardDataService dataService) + public DashboardService( + IDashboardDataService dataService, + IWorkOrderAccountResolver accountResolver) { _dataService = dataService; + _accountResolver = accountResolver; } - public async Task GetStatsAsync(CancellationToken cancellationToken) + public async Task GetStatsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) { - var counts = await _dataService.GetWorkOrderCountsAsync(cancellationToken); + var accountId = _accountResolver.ResolveAccountFilter(user); + var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); return new DashboardStatsDTO { diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index bfc2e2d..63379e1 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -1,9 +1,12 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IDashboardService { - Task GetStatsAsync(CancellationToken cancellationToken); + Task GetStatsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken); } } From a2e749c72db356b4c424ae96b3166517f0204a4f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:28:21 -0300 Subject: [PATCH 03/28] feat(dashboard): add core dashboard metrics (SH-343) --- .../DashboardServiceTests.cs | 95 ++++++++++++++- .../Controllers/DashboardController.cs | 14 ++- .../Implementation/DashboardDataService.cs | 37 ++++++ .../Interfaces/IDashboardDataService.cs | 19 +++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 20 ++++ .../Helpers/DashboardBusinessTime.cs | 13 ++ SeaHaven.Services/Helpers/DashboardMetrics.cs | 113 ++++++++++++++++++ .../Implementation/DashboardService.cs | 31 ++++- .../Interfaces/IDashboardService.cs | 1 + 9 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 SeaHaven.Services/Helpers/DashboardBusinessTime.cs create mode 100644 SeaHaven.Services/Helpers/DashboardMetrics.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index c45ab1a..a5dadfb 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; @@ -28,12 +29,16 @@ public class DashboardServiceTests return new DashboardService(new DashboardDataService(ctx), resolver); } - private static ClaimsPrincipal AccountUser(int accountId) + private static ClaimsPrincipal AccountUser( + int accountId, + string userId = "dispatcher-1", + string role = "Admin") { var claims = new[] { new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), - new Claim(ClaimTypes.Role, "Dispatcher") + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) }; return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } @@ -66,7 +71,8 @@ public class DashboardServiceTests ); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(AccountUser(1), CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); stats.Total.Should().Be(7); stats.Open.Should().Be(4); @@ -84,7 +90,8 @@ public class DashboardServiceTests new WorkOrder { Status = "Open", istemplate = true }); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(OrgWideUser(), CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync( + OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None); stats.Total.Should().Be(2); stats.Open.Should().Be(1); @@ -100,10 +107,88 @@ public class DashboardServiceTests new Claim(ClaimTypes.Role, "Dispatcher") }, "test")); - var act = () => NewService(ctx).GetStatsAsync(user, CancellationToken.None); + var act = () => NewService(ctx).GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); var exception = await act.Should().ThrowAsync(); exception.Which.Code.Should().Be("Forbidden"); } + + [Fact] + public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-6) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress, + ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-15), + CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete, + ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO + { + DateFrom = today.AddDays(-10), + DateTo = today.AddDays(10) + }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), query, CancellationToken.None); + + stats.Total.Should().Be(4); + stats.Breakdown.Overdue.Should().Be(1); + stats.Breakdown.Other.Should().Be(0); + stats.Breakdown.PM.Should().Be(2); + stats.Breakdown.Emergency.Should().Be(0); + stats.Breakdown.Reactive.Should().Be(1); + (stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive + + stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total); + stats.DueCount.Should().Be(3); + stats.CompletedDueCount.Should().Be(1); + stats.CompletionRate.Should().Be(33.33m); + stats.AverageResolutionDays.Should().Be(3m); + } + + [Fact] + public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders() + { + 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, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(1); + stats.Open.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 4841c82..cb7e27c 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -17,16 +18,23 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("Stats")] - public async Task GetStats(CancellationToken cancellationToken) + public async Task GetStats( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken) { - var stats = await _dashboardService.GetStatsAsync(User, cancellationToken); + var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken); return Ok(new { total = stats.Total, open = stats.Open, notDispatched = stats.NotDispatched, - completed = stats.Completed + completed = stats.Completed, + breakdown = stats.Breakdown, + dueCount = stats.DueCount, + completedDueCount = stats.CompletedDueCount, + completionRate = stats.CompletionRate, + averageResolutionDays = stats.AverageResolutionDays }); } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 8bf21c9..73c073e 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -36,5 +38,40 @@ namespace SeaHaven.DataServices.Implementation Completed = completed }; } + + public async Task> GetDashboardWorkOrdersAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId); + + if (dispatcherId != null) + query = query.Where(w => w.AssignTo == dispatcherId); + + if (dateFrom is DateOnly from && dateTo is DateOnly to) + { + var fromDate = from.ToDateTime(TimeOnly.MinValue); + var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue); + query = query.Where(w => + w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive); + } + + return await query + .Select(w => new DashboardWorkOrder( + w.Id, + w.AssignTo, + w.WorkOrderType, + w.LifecycleStatus, + w.LegacyStatus ?? w.Status, + w.ScheduledDate, + w.OriginalDate, + w.CompletedDate)) + .ToListAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 5993609..470e70e 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -1,12 +1,31 @@ namespace SeaHaven.DataServices.Interfaces { + using Data.SeaHavenIndustries.Enums; + public interface IDashboardDataService { Task GetWorkOrderCountsAsync( int? accountId, CancellationToken cancellationToken); + + Task> GetDashboardWorkOrdersAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); } + public sealed record DashboardWorkOrder( + int Id, + string? AssignTo, + WorkOrderType? WorkOrderType, + LifecycleStatus? LifecycleStatus, + string? LegacyStatus, + DateTime? ScheduledDate, + DateOnly? OriginalDate, + DateTime? CompletedDate); + public class DashboardCounts { public int Total { get; set; } diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 6bac68c..25ecafc 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -1,10 +1,30 @@ namespace SeaHaven.Services.DTOs { + public sealed class DashboardStatsQueryDTO + { + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } + } + public class DashboardStatsDTO { public int Total { get; set; } public int Open { get; set; } public int NotDispatched { get; set; } public int Completed { get; set; } + public DashboardBreakdownDTO Breakdown { get; set; } = new(); + public int DueCount { get; set; } + public int CompletedDueCount { get; set; } + public decimal CompletionRate { get; set; } + public decimal? AverageResolutionDays { get; set; } + } + + public sealed class DashboardBreakdownDTO + { + public int PM { get; set; } + public int Emergency { get; set; } + public int Reactive { get; set; } + public int Overdue { get; set; } + public int Other { get; set; } } } diff --git a/SeaHaven.Services/Helpers/DashboardBusinessTime.cs b/SeaHaven.Services/Helpers/DashboardBusinessTime.cs new file mode 100644 index 0000000..2ad280d --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardBusinessTime.cs @@ -0,0 +1,13 @@ +namespace SeaHaven.Services.Helpers; + +public static class DashboardBusinessTime +{ + public const string TimeZoneId = "America/New_York"; + + public static DateOnly Today() + { + var timezone = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId); + var businessNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timezone); + return DateOnly.FromDateTime(businessNow); + } +} diff --git a/SeaHaven.Services/Helpers/DashboardMetrics.cs b/SeaHaven.Services/Helpers/DashboardMetrics.cs new file mode 100644 index 0000000..e49d921 --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardMetrics.cs @@ -0,0 +1,113 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers; + +public static class DashboardMetrics +{ + public static DashboardMetricResult Calculate( + IReadOnlyList workOrders, + DateOnly today) + { + var breakdown = new DashboardBreakdownDTO(); + var dueCount = 0; + var completedDueCount = 0; + var resolutionDays = new List(); + + foreach (var workOrder in workOrders) + { + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + var completed = status == LifecycleStatus.Completed; + var canceled = status == LifecycleStatus.Canceled; + var pastDue = IsPastDue(workOrder, status, today); + + AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue); + + var due = completed || (workOrder.ScheduledDate is DateTime scheduled + && DateOnly.FromDateTime(scheduled.Date) <= today); + if (due) + { + dueCount++; + if (completed) + completedDueCount++; + } + + if (completed && workOrder.CompletedDate is DateTime completedDate) + { + var anchor = workOrder.OriginalDate + ?? (workOrder.ScheduledDate is DateTime scheduledDate + ? DateOnly.FromDateTime(scheduledDate.Date) + : null); + if (anchor is DateOnly originalDate) + resolutionDays.Add((decimal)(completedDate.Date - originalDate.ToDateTime(TimeOnly.MinValue)).TotalDays); + } + } + + return new DashboardMetricResult( + workOrders.Count, + workOrders.Count(workOrder => + { + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + return status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled; + }), + workOrders.Count(workOrder => string.IsNullOrWhiteSpace(workOrder.AssignTo)), + workOrders.Count(workOrder => + (workOrder.LifecycleStatus ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus)) + == LifecycleStatus.Completed), + breakdown, + dueCount, + completedDueCount, + dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2), + resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2)); + } + + private static bool IsPastDue( + DashboardWorkOrder workOrder, + LifecycleStatus? status, + DateOnly today) + => workOrder.ScheduledDate is DateTime scheduled + && DateOnly.FromDateTime(scheduled.Date) < today + && status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled; + + private static void AddBreakdownCount( + DashboardBreakdownDTO breakdown, + WorkOrderType? type, + bool pastDue) + { + if (pastDue) + { + breakdown.Overdue++; + return; + } + + switch (type) + { + case WorkOrderType.PM: + breakdown.PM++; + break; + case WorkOrderType.Emergency: + breakdown.Emergency++; + break; + case WorkOrderType.Reactive: + breakdown.Reactive++; + break; + default: + breakdown.Other++; + break; + } + } +} + +public sealed record DashboardMetricResult( + int Total, + int Open, + int NotDispatched, + int Completed, + DashboardBreakdownDTO Breakdown, + int DueCount, + int CompletedDueCount, + decimal CompletionRate, + decimal? AverageResolutionDays); diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 30695b9..afe4cd0 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,6 +1,8 @@ using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -20,17 +22,38 @@ namespace SeaHaven.Services.Implementation public async Task GetStatsAsync( ClaimsPrincipal user, + DashboardStatsQueryDTO query, 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 counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); + var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today()); + + var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null; return new DashboardStatsDTO { - Total = counts.Total, - Open = counts.Open, - NotDispatched = counts.NotDispatched, - Completed = counts.Completed + Total = useMetrics ? metrics.Total : counts.Total, + Open = useMetrics ? metrics.Open : counts.Open, + NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched, + Completed = useMetrics ? metrics.Completed : counts.Completed, + Breakdown = metrics.Breakdown, + DueCount = metrics.DueCount, + CompletedDueCount = metrics.CompletedDueCount, + CompletionRate = metrics.CompletionRate, + AverageResolutionDays = metrics.AverageResolutionDays }; } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 63379e1..a760d47 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -7,6 +7,7 @@ namespace SeaHaven.Services.Interfaces { Task GetStatsAsync( ClaimsPrincipal user, + DashboardStatsQueryDTO query, CancellationToken cancellationToken); } } From dec8b15278b73b7379568fd44c605139b3626831 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:31:21 -0300 Subject: [PATCH 04/28] 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 9b39d0e5ecab63fd3a297825dbd5192118d2ae27 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:43:49 -0300 Subject: [PATCH 05/28] feat(dashboard): add dispatcher workload (SH-346) --- .../DashboardServiceTests.cs | 47 ++++++++++++ .../Controllers/DashboardController.cs | 26 +++++++ .../Implementation/DashboardDataService.cs | 74 +++++++++++++++++++ .../Interfaces/IDashboardDataService.cs | 13 ++++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 16 ++++ .../Implementation/DashboardService.cs | 45 +++++++++++ .../Interfaces/IDashboardService.cs | 6 ++ 7 files changed, 227 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index a5dadfb..3d426c9 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Exceptions; @@ -191,4 +192,50 @@ public class DashboardServiceTests stats.Total.Should().Be(1); stats.Open.Should().Be(1); } + + [Fact] + public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount() + { + 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", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.TotalDispatchers.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].DispatcherName.Should().Be("Ada One"); + result.Items[0].TotalCount.Should().Be(2); + result.Items[0].OpenCount.Should().Be(1); + result.PageSize.Should().Be(10); + + var adminResult = await NewService(ctx).GetWorkloadAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + adminResult.TotalDispatchers.Should().Be(2); + adminResult.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index cb7e27c..91fc35e 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -37,5 +37,31 @@ namespace Api.SeaHavenIndustries.Controllers averageResolutionDays = stats.AverageResolutionDays }); } + + [HttpGet("Workload")] + public async Task GetWorkload( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetWorkloadAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 73c073e..f571302 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -73,5 +73,79 @@ namespace SeaHaven.DataServices.Implementation w.CompletedDate)) .ToListAsync(cancellationToken); } + + public async Task> GetDispatcherWorkloadAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + var dispatcherUsers = from user in _context.Users.AsNoTracking() + join userRole in _context.UserRoles + on user.Id equals userRole.UserId + join role in _context.Roles + 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, + Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim() + }; + + var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); + + if (dateFrom is DateOnly from && dateTo is DateOnly to) + { + var fromDate = from.ToDateTime(TimeOnly.MinValue); + var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue); + workOrders = workOrders.Where(w => + w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive); + } + + var counts = await workOrders + .Where(w => w.AssignTo != null) + .GroupBy(w => w.AssignTo!) + .Select(group => new + { + DispatcherId = group.Key, + TotalCount = group.Count(), + OpenCount = group.Count(w => + 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")) + }) + .ToListAsync(cancellationToken); + + var dispatcherList = await dispatcherUsers + .Distinct() + .ToListAsync(cancellationToken); + + return dispatcherList + .GroupJoin( + counts, + user => user.Id, + count => count.DispatcherId, + (user, matchingCounts) => + { + var count = matchingCounts.SingleOrDefault(); + return new DashboardDispatcherWorkload( + user.Id, + user.Name, + count?.TotalCount ?? 0, + count?.OpenCount ?? 0); + }) + .ToList(); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 470e70e..dcc77b1 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -14,6 +14,13 @@ namespace SeaHaven.DataServices.Interfaces DateOnly? dateFrom, DateOnly? dateTo, CancellationToken cancellationToken); + + Task> GetDispatcherWorkloadAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); } public sealed record DashboardWorkOrder( @@ -26,6 +33,12 @@ namespace SeaHaven.DataServices.Interfaces DateOnly? OriginalDate, DateTime? CompletedDate); + public sealed record DashboardDispatcherWorkload( + string DispatcherId, + string DispatcherName, + int TotalCount, + int OpenCount); + public class DashboardCounts { public int Total { get; set; } diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 25ecafc..8af4d3f 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -6,6 +6,22 @@ namespace SeaHaven.Services.DTOs public DateOnly? DateTo { get; set; } } + public sealed class DashboardWorkloadResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalDispatchers { get; init; } + } + + public sealed class DashboardWorkloadRowDTO + { + public string DispatcherId { get; init; } = string.Empty; + public string DispatcherName { get; init; } = string.Empty; + public int TotalCount { get; init; } + public int OpenCount { get; init; } + } + public class DashboardStatsDTO { public int Total { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index afe4cd0..ef6364c 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -56,5 +56,50 @@ namespace SeaHaven.Services.Implementation AverageResolutionDays = metrics.AverageResolutionDays }; } + + public async Task GetWorkloadAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken) + { + if (page < 1) + 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 rows = await _dataService.GetDispatcherWorkloadAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); + var orderedRows = rows + .OrderByDescending(row => row.OpenCount) + .ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DispatcherId, StringComparer.Ordinal) + .ToList(); + const int pageSize = 10; + + return new DashboardWorkloadResponseDTO + { + Items = orderedRows.Skip((page - 1) * pageSize).Take(pageSize) + .Select(row => new DashboardWorkloadRowDTO + { + DispatcherId = row.DispatcherId, + DispatcherName = row.DispatcherName, + TotalCount = row.TotalCount, + OpenCount = row.OpenCount + }).ToList(), + Page = page, + PageSize = pageSize, + TotalDispatchers = orderedRows.Count + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index a760d47..cdb572c 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -9,5 +9,11 @@ namespace SeaHaven.Services.Interfaces ClaimsPrincipal user, DashboardStatsQueryDTO query, CancellationToken cancellationToken); + + Task GetWorkloadAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken); } } From d6c3cd2e24ad740a9efa66928452e391dfc647c7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:44:00 -0300 Subject: [PATCH 06/28] feat(permissions): add team member permission policy foundation (SH-327) --- .../Auth/ApplicationDbContext.cs | 27 + .../Auth/UserPermissionOverride.cs | 18 + .../Enums/PermissionEnums.cs | 13 + ..._SH327_UserPermissionOverrides.Designer.cs | 3966 +++++++++++++++++ ...916201532_SH327_UserPermissionOverrides.cs | 46 + .../ApplicationDbContextModelSnapshot.cs | 33 + .../TeamPermissionPolicyTests.cs | 248 ++ .../Constants/TeamPermissionKeys.cs | 67 + .../Implementation/TeamPermissionPolicy.cs | 88 + .../Interfaces/ITeamPermissionPolicy.cs | 23 + 10 files changed, 4529 insertions(+) create mode 100644 Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs create mode 100644 Data.SeaHavenIndustries/Enums/PermissionEnums.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.cs create mode 100644 SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs create mode 100644 SeaHaven.Services/Constants/TeamPermissionKeys.cs create mode 100644 SeaHaven.Services/Implementation/TeamPermissionPolicy.cs create mode 100644 SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index c8ac67b..f322ed8 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -225,6 +225,32 @@ namespace Data.SeaHavenIndustries .WithMany() .HasForeignKey(c => c.AreaId) .OnDelete(DeleteBehavior.Restrict); + + // Per-person team permission overrides. Composite primary key + // (UserId + PermissionKey) plus an explicitly named unique composite + // index; missing rows behave as Unset. + builder.Entity(entity => + { + entity.HasKey(o => new { o.UserId, o.PermissionKey }); + + entity.Property(o => o.UserId) + .HasMaxLength(450); + + entity.Property(o => o.PermissionKey) + .HasMaxLength(64); + + entity.Property(o => o.State) + .IsRequired(); + + entity.HasOne(o => o.User) + .WithMany() + .HasForeignKey(o => o.UserId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasIndex(o => new { o.UserId, o.PermissionKey }) + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + }); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -279,6 +305,7 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + public DbSet UserPermissionOverrides { get; set; } public override int SaveChanges() { diff --git a/Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs b/Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs new file mode 100644 index 0000000..829daa8 --- /dev/null +++ b/Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs @@ -0,0 +1,18 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + /// + /// Per-person team permission override, keyed by user and canonical + /// permission key. Explicit Allow/Deny rows take precedence over role defaults; + /// (and a missing row) falls back to + /// the role default. + /// + public class UserPermissionOverride + { + public string UserId { get; set; } = null!; + public string PermissionKey { get; set; } = null!; + public UserPermissionState State { get; set; } = UserPermissionState.Unset; + public virtual ApplicationUser? User { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Enums/PermissionEnums.cs b/Data.SeaHavenIndustries/Enums/PermissionEnums.cs new file mode 100644 index 0000000..9c6b014 --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/PermissionEnums.cs @@ -0,0 +1,13 @@ +namespace Data.SeaHavenIndustries.Enums +{ + /// + /// Tri-state state of a per-person team permission override. + /// A missing row behaves the same as . + /// + public enum UserPermissionState + { + Unset = 0, + Allow = 1, + Deny = 2 + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs new file mode 100644 index 0000000..c150a1c --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs @@ -0,0 +1,3966 @@ +// +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("20260916201532_SH327_UserPermissionOverrides")] + partial class SH327_UserPermissionOverrides + { + /// + 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.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.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + 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.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.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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/20260916201532_SH327_UserPermissionOverrides.cs b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.cs new file mode 100644 index 0000000..732c20d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH327_UserPermissionOverrides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserPermissionOverrides", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + PermissionKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + State = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserPermissionOverrides", x => new { x.UserId, x.PermissionKey }); + table.ForeignKey( + name: "FK_UserPermissionOverrides_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserPermissionOverrides_UserId_PermissionKey", + table: "UserPermissionOverrides", + columns: new[] { "UserId", "PermissionKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserPermissionOverrides"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 2120abc..b68965a 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2072,6 +2072,28 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("Trades"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => { b.Property("Id") @@ -3616,6 +3638,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("POC"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => { b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") diff --git a/SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs b/SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs new file mode 100644 index 0000000..0294e22 --- /dev/null +++ b/SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs @@ -0,0 +1,248 @@ +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using SeaHaven.Services.Constants; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace SeaHaven.Services.Tests; + +public class TeamPermissionPolicyTests +{ + private static readonly string[] DispatcherKeys = + { + TeamPermissionKeys.CreateVendors, + TeamPermissionKeys.EditVendors, + TeamPermissionKeys.DeactivateVendors, + TeamPermissionKeys.CreateSites, + TeamPermissionKeys.EditSites, + TeamPermissionKeys.CreateWorkOrders, + TeamPermissionKeys.EditOthersWorkOrders, + TeamPermissionKeys.CancelWorkOrders, + TeamPermissionKeys.RequestUplifts, + TeamPermissionKeys.AutoApproveUplifts + }; + + private static readonly string[] SchedulerOnlyKeys = + { + TeamPermissionKeys.DeleteSites, + TeamPermissionKeys.CreateServices, + TeamPermissionKeys.EditServices, + TeamPermissionKeys.CreateCompletionDocTemplates, + TeamPermissionKeys.EditCompletionDocTemplates, + TeamPermissionKeys.ViewAllDispatchersOnDashboard + }; + + private static readonly string[] AdminOnlyKeys = + { + TeamPermissionKeys.ManageTeamMembers, + TeamPermissionKeys.ChangeTeamMemberRole, + TeamPermissionKeys.DeactivateServices, + TeamPermissionKeys.DeleteCompletionDocTemplates, + TeamPermissionKeys.DeleteWorkOrders, + TeamPermissionKeys.ReviewUplifts + }; + + private static IReadOnlyDictionary Overrides( + string key, UserPermissionState state) => + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [key] = state + }; + + [Fact] + public void Registry_Exposes_Exactly_The_22_Prototype_Keys() + { + TeamPermissionKeys.All.Should().HaveCount(22); + TeamPermissionKeys.KnownKeys.Should().HaveCount(22); + TeamPermissionKeys.All.Should().OnlyHaveUniqueItems(); + TeamPermissionKeys.All.Should().OnlyContain(key => !string.IsNullOrWhiteSpace(key)); + TeamPermissionKeys.All.Should().BeEquivalentTo(new[] + { + "manageTeamMembers", + "changeTeamMemberRole", + "createVendors", + "editVendors", + "deactivateVendors", + "createSites", + "editSites", + "deleteSites", + "createServices", + "editServices", + "deactivateServices", + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", + "createWorkOrders", + "editOthersWorkOrders", + "cancelWorkOrders", + "deleteWorkOrders", + "requestUplifts", + "autoApproveUplifts", + "reviewUplifts", + "viewAllDispatchersOnDashboard" + }); + } + + [Fact] + public void Registry_IsKnown_Is_Case_Insensitive_And_Rejects_Unknown_Keys() + { + TeamPermissionKeys.IsKnown("CREATEVENDORS").Should().BeTrue(); + TeamPermissionKeys.IsKnown("autoApproveUplifts").Should().BeTrue(); + TeamPermissionKeys.IsKnown("nope.unknown").Should().BeFalse(); + TeamPermissionKeys.IsKnown("").Should().BeFalse(); + TeamPermissionKeys.IsKnown(null).Should().BeFalse(); + } + + [Fact] + public void Dispatcher_Gets_Exactly_Prototype_Defaults() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + { + var expected = DispatcherKeys.Contains(key, StringComparer.Ordinal); + policy.IsAllowed("Dispatcher", key).Should().Be(expected, + $"Dispatcher default for '{key}' should be {expected}"); + } + } + + [Fact] + public void Scheduler_Gets_Exactly_Prototype_Defaults() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + { + var expected = SchedulerOnlyKeys.Contains(key, StringComparer.Ordinal) + || DispatcherKeys.Contains(key, StringComparer.Ordinal) + && key is not TeamPermissionKeys.RequestUplifts + && key is not TeamPermissionKeys.AutoApproveUplifts; + + policy.IsAllowed("Scheduler", key).Should().Be(expected, + $"Scheduler default for '{key}' should be {expected}"); + } + } + + [Fact] + public void Admin_Has_Effective_Access_To_Every_Key() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + policy.IsAllowed("Admin", key).Should().BeTrue($"Admin should hold '{key}'"); + } + + [Fact] + public void Role_Recognition_Is_Case_Insensitive() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.CreateVendors).Should().BeTrue(); + policy.IsAllowed("SCHEDULER", TeamPermissionKeys.DeleteSites).Should().BeTrue(); + policy.IsAllowed("admin", TeamPermissionKeys.ReviewUplifts).Should().BeTrue(); + + policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.DeleteSites).Should().BeFalse(); + policy.IsAllowed("SCHEDULER", TeamPermissionKeys.AutoApproveUplifts).Should().BeFalse(); + } + + [Theory] + [InlineData("Manager")] + [InlineData("OfficeAdmin")] + [InlineData("")] + [InlineData(null)] + public void Unknown_And_Legacy_Roles_Receive_No_Permissions(string? role) + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + policy.IsAllowed(role, key).Should().BeFalse( + $"unknown/legacy role '{role}' must not hold '{key}'"); + } + + [Fact] + public void Unknown_Role_Gains_Nothing_Even_With_Allow_Overrides() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + policy.IsAllowed("Manager", key, Overrides(key, UserPermissionState.Allow)) + .Should().BeFalse($"an unknown role must not be elevated via '{key}'"); + } + + [Fact] + public void Allow_Override_Grants_Key_Outside_Role_Default() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("Scheduler", TeamPermissionKeys.RequestUplifts) + .Should().BeFalse(); + policy.IsAllowed( + "Scheduler", + TeamPermissionKeys.RequestUplifts, + Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Allow)) + .Should().BeTrue(); + } + + [Fact] + public void Deny_Override_Removes_Key_Inside_Role_Default() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("Dispatcher", TeamPermissionKeys.CreateVendors) + .Should().BeTrue(); + policy.IsAllowed( + "Dispatcher", + TeamPermissionKeys.CreateVendors, + Overrides(TeamPermissionKeys.CreateVendors, UserPermissionState.Deny)) + .Should().BeFalse(); + } + + [Fact] + public void Unset_Override_And_Missing_Row_Fall_Back_To_Role_Default() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed( + "Dispatcher", + TeamPermissionKeys.CreateWorkOrders, + Overrides(TeamPermissionKeys.CreateWorkOrders, UserPermissionState.Unset)) + .Should().BeTrue(); + + policy.IsAllowed( + "Scheduler", + TeamPermissionKeys.RequestUplifts, + Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Unset)) + .Should().BeFalse(); + + policy.IsAllowed( + "Scheduler", + TeamPermissionKeys.RequestUplifts, + new Dictionary()) + .Should().BeFalse(); + } + + [Fact] + public void Admin_Is_Immune_To_Overrides() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + { + policy.IsAllowed("Admin", key, Overrides(key, UserPermissionState.Deny)) + .Should().BeTrue($"Admin must keep '{key}' despite a Deny override"); + policy.IsAllowed("ADMIN", key, Overrides(key, UserPermissionState.Unset)) + .Should().BeTrue(); + } + } + + [Fact] + public void Unknown_Permission_Key_Is_Denied_For_Every_Role() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("Admin", "nope.unknown").Should().BeFalse(); + policy.IsAllowed("Dispatcher", "nope.unknown").Should().BeFalse(); + policy.IsAllowed("Admin", "nope.unknown", Overrides("nope.unknown", UserPermissionState.Allow)) + .Should().BeFalse(); + } +} diff --git a/SeaHaven.Services/Constants/TeamPermissionKeys.cs b/SeaHaven.Services/Constants/TeamPermissionKeys.cs new file mode 100644 index 0000000..ceae372 --- /dev/null +++ b/SeaHaven.Services/Constants/TeamPermissionKeys.cs @@ -0,0 +1,67 @@ +namespace SeaHaven.Services.Constants +{ + /// + /// Canonical, immutable registry of team-members permission keys. + /// Exactly the 22 approved prototype keys; stored overrides must reference + /// these keys. Keys are stable identifiers and must never be renamed. + /// + public static class TeamPermissionKeys + { + public const string ManageTeamMembers = "manageTeamMembers"; + public const string ChangeTeamMemberRole = "changeTeamMemberRole"; + public const string CreateVendors = "createVendors"; + public const string EditVendors = "editVendors"; + public const string DeactivateVendors = "deactivateVendors"; + public const string CreateSites = "createSites"; + public const string EditSites = "editSites"; + public const string DeleteSites = "deleteSites"; + public const string CreateServices = "createServices"; + public const string EditServices = "editServices"; + public const string DeactivateServices = "deactivateServices"; + public const string CreateCompletionDocTemplates = "createCompletionDocTemplates"; + public const string EditCompletionDocTemplates = "editCompletionDocTemplates"; + public const string DeleteCompletionDocTemplates = "deleteCompletionDocTemplates"; + public const string CreateWorkOrders = "createWorkOrders"; + public const string EditOthersWorkOrders = "editOthersWorkOrders"; + public const string CancelWorkOrders = "cancelWorkOrders"; + public const string DeleteWorkOrders = "deleteWorkOrders"; + public const string RequestUplifts = "requestUplifts"; + public const string AutoApproveUplifts = "autoApproveUplifts"; + public const string ReviewUplifts = "reviewUplifts"; + public const string ViewAllDispatchersOnDashboard = "viewAllDispatchersOnDashboard"; + + private static readonly IReadOnlyList AllKeys = Array.AsReadOnly(new[] + { + ManageTeamMembers, + ChangeTeamMemberRole, + CreateVendors, + EditVendors, + DeactivateVendors, + CreateSites, + EditSites, + DeleteSites, + CreateServices, + EditServices, + DeactivateServices, + CreateCompletionDocTemplates, + EditCompletionDocTemplates, + DeleteCompletionDocTemplates, + CreateWorkOrders, + EditOthersWorkOrders, + CancelWorkOrders, + DeleteWorkOrders, + RequestUplifts, + AutoApproveUplifts, + ReviewUplifts, + ViewAllDispatchersOnDashboard + }); + + public static IReadOnlyList All => AllKeys; + + public static IReadOnlySet KnownKeys { get; } = + new HashSet(AllKeys, StringComparer.OrdinalIgnoreCase); + + public static bool IsKnown(string? permissionKey) => + !string.IsNullOrWhiteSpace(permissionKey) && KnownKeys.Contains(permissionKey); + } +} diff --git a/SeaHaven.Services/Implementation/TeamPermissionPolicy.cs b/SeaHaven.Services/Implementation/TeamPermissionPolicy.cs new file mode 100644 index 0000000..31faa86 --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamPermissionPolicy.cs @@ -0,0 +1,88 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Constants; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public sealed class TeamPermissionPolicy : ITeamPermissionPolicy + { + private static readonly StringComparer KeyComparer = StringComparer.OrdinalIgnoreCase; + + private static readonly HashSet DispatcherDefaults = new(KeyComparer) + { + TeamPermissionKeys.CreateVendors, + TeamPermissionKeys.EditVendors, + TeamPermissionKeys.DeactivateVendors, + TeamPermissionKeys.CreateSites, + TeamPermissionKeys.EditSites, + TeamPermissionKeys.CreateWorkOrders, + TeamPermissionKeys.EditOthersWorkOrders, + TeamPermissionKeys.CancelWorkOrders, + TeamPermissionKeys.RequestUplifts, + TeamPermissionKeys.AutoApproveUplifts + }; + + private static readonly HashSet SchedulerDefaults = new( + DispatcherDefaults + .Except(new[] + { + TeamPermissionKeys.RequestUplifts, + TeamPermissionKeys.AutoApproveUplifts + }, KeyComparer) + .Concat(new[] + { + TeamPermissionKeys.DeleteSites, + TeamPermissionKeys.CreateServices, + TeamPermissionKeys.EditServices, + TeamPermissionKeys.CreateCompletionDocTemplates, + TeamPermissionKeys.EditCompletionDocTemplates, + TeamPermissionKeys.ViewAllDispatchersOnDashboard + }), KeyComparer); + + public bool IsAllowed( + string? roleName, + string permissionKey, + IReadOnlyDictionary? overrides = null) + { + if (!TeamPermissionKeys.IsKnown(permissionKey)) + return false; + + if (IsAdmin(roleName)) + return true; + + var defaults = ResolveRoleDefaults(roleName); + if (defaults is null) + return false; + + if (overrides is not null + && overrides.TryGetValue(permissionKey, out var state)) + { + return state switch + { + UserPermissionState.Allow => true, + UserPermissionState.Deny => false, + _ => defaults.Contains(permissionKey) + }; + } + + return defaults.Contains(permissionKey); + } + + private static bool IsAdmin(string? roleName) => + string.Equals(roleName, "Admin", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlySet? ResolveRoleDefaults(string? roleName) + { + if (string.IsNullOrWhiteSpace(roleName)) + return null; + + if (roleName.Equals("Dispatcher", StringComparison.OrdinalIgnoreCase)) + return DispatcherDefaults; + + if (roleName.Equals("Scheduler", StringComparison.OrdinalIgnoreCase)) + return SchedulerDefaults; + + return null; + } + } +} diff --git a/SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs b/SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs new file mode 100644 index 0000000..1d66616 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs @@ -0,0 +1,23 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Interfaces +{ + /// + /// Pure evaluator for team-members permissions. Applies explicit + /// per-person overrides on top of role defaults; Admin has effective access + /// to every canonical key regardless of stored values. Unknown or legacy + /// roles receive no permissions. + /// + public interface ITeamPermissionPolicy + { + /// + /// Resolves whether a user in holds + /// . is the + /// person's stored override set (missing key behaves as Unset). + /// + bool IsAllowed( + string? roleName, + string permissionKey, + IReadOnlyDictionary? overrides = null); + } +} From 0194748e3b8fc989b5c890e93c12df651636f44a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:58:08 -0300 Subject: [PATCH 07/28] feat(dashboard): add dispatcher performance (SH-347) --- .../DashboardServiceTests.cs | 63 +++++++++++++++++++ .../Controllers/DashboardController.cs | 26 ++++++++ .../Implementation/DashboardDataService.cs | 12 ++-- .../Interfaces/IDashboardDataService.cs | 1 + SeaHaven.Services/DTOs/DashboardDTOs.cs | 18 ++++++ .../Implementation/DashboardService.cs | 51 +++++++++++++++ .../Interfaces/IDashboardService.cs | 6 ++ 7 files changed, 172 insertions(+), 5 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 3d426c9..ff65c94 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -237,5 +237,68 @@ public class DashboardServiceTests adminResult.TotalDispatchers.Should().Be(2); adminResult.Items.Select(item => item.DispatcherName) .Should().ContainInOrder("Ada One", "Ben Two"); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items[0].DispatcherName.Should().Be("Ada One"); + } + + [Fact] + public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution() + { + 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", + Color = "#123456" + }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-5), + CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].CompletionRate.Should().Be(100m); + result.Items[0].AverageResolutionDays.Should().Be(3m); + result.Items[0].Color.Should().Be("#123456"); } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 91fc35e..aff03f3 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -63,5 +63,31 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(ex.Message); } } + + [HttpGet("Performance")] + public async Task GetPerformance( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetPerformanceAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index f571302..14b1ffa 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -92,7 +92,8 @@ namespace SeaHaven.DataServices.Implementation select new { user.Id, - Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim() + Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(), + user.Color }; var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); @@ -140,10 +141,11 @@ namespace SeaHaven.DataServices.Implementation { var count = matchingCounts.SingleOrDefault(); return new DashboardDispatcherWorkload( - user.Id, - user.Name, - count?.TotalCount ?? 0, - count?.OpenCount ?? 0); + user.Id, + user.Name, + user.Color, + count?.TotalCount ?? 0, + count?.OpenCount ?? 0); }) .ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index dcc77b1..bd3a394 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -36,6 +36,7 @@ namespace SeaHaven.DataServices.Interfaces public sealed record DashboardDispatcherWorkload( string DispatcherId, string DispatcherName, + string? Color, int TotalCount, int OpenCount); diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 8af4d3f..6034544 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -18,10 +18,28 @@ namespace SeaHaven.Services.DTOs { public string DispatcherId { get; init; } = string.Empty; public string DispatcherName { get; init; } = string.Empty; + public string? Color { get; init; } public int TotalCount { get; init; } public int OpenCount { get; init; } } + public sealed class DashboardPerformanceResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalDispatchers { get; init; } + } + + public sealed class DashboardPerformanceRowDTO + { + public string DispatcherId { get; init; } = string.Empty; + public string DispatcherName { get; init; } = string.Empty; + public string? Color { get; init; } + public decimal CompletionRate { get; init; } + public decimal? AverageResolutionDays { get; init; } + } + public class DashboardStatsDTO { public int Total { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index ef6364c..aaad3f1 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -93,6 +93,7 @@ namespace SeaHaven.Services.Implementation { DispatcherId = row.DispatcherId, DispatcherName = row.DispatcherName, + Color = row.Color, TotalCount = row.TotalCount, OpenCount = row.OpenCount }).ToList(), @@ -101,5 +102,55 @@ namespace SeaHaven.Services.Implementation TotalDispatchers = orderedRows.Count }; } + + public async Task GetPerformanceAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken) + { + if (page < 1) + 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 directory = await _dataService.GetDispatcherWorkloadAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var today = DashboardBusinessTime.Today(); + var rows = directory.Select(dispatcher => + { + var metrics = DashboardMetrics.Calculate( + workOrders.Where(workOrder => workOrder.AssignTo == dispatcher.DispatcherId).ToList(), + today); + return new DashboardPerformanceRowDTO + { + DispatcherId = dispatcher.DispatcherId, + DispatcherName = dispatcher.DispatcherName, + Color = dispatcher.Color, + CompletionRate = metrics.CompletionRate, + AverageResolutionDays = metrics.AverageResolutionDays + }; + }) + .OrderByDescending(row => row.CompletionRate) + .ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DispatcherId, StringComparer.Ordinal) + .ToList(); + const int pageSize = 10; + + return new DashboardPerformanceResponseDTO + { + Items = rows.Skip((page - 1) * pageSize).Take(pageSize).ToList(), + Page = page, + PageSize = pageSize, + TotalDispatchers = rows.Count + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index cdb572c..8b74dd2 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -15,5 +15,11 @@ namespace SeaHaven.Services.Interfaces DashboardStatsQueryDTO query, int page, CancellationToken cancellationToken); + + Task GetPerformanceAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken); } } From 441735d39d30b23e724dd0b90480f8854ff6baa6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:02:02 -0300 Subject: [PATCH 08/28] feat(permissions): expose team member permission overrides (SH-328) --- .../TeamPermissionOverrideDataServiceTests.cs | 80 +++++++++++ .../Controllers/TeamPermissionController.cs | 65 +++++++++ .../Dto/TeamPermissionReadModels.cs | 11 ++ .../TeamPermissionOverrideDataService.cs | 84 +++++++++++ .../ITeamPermissionOverrideDataService.cs | 14 ++ .../TeamPermissionServiceTests.cs | 130 ++++++++++++++++++ SeaHaven.Services/DTOs/TeamPermissionDTOs.cs | 49 +++++++ .../Implementation/TeamPermissionService.cs | 96 +++++++++++++ .../Interfaces/ITeamPermissionService.cs | 20 +++ 9 files changed, 549 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs create mode 100644 SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs create mode 100644 SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs create mode 100644 SeaHaven.Services.Tests/TeamPermissionServiceTests.cs create mode 100644 SeaHaven.Services/DTOs/TeamPermissionDTOs.cs create mode 100644 SeaHaven.Services/Implementation/TeamPermissionService.cs create mode 100644 SeaHaven.Services/Interfaces/ITeamPermissionService.cs diff --git a/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs new file mode 100644 index 0000000..0f0861e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs @@ -0,0 +1,80 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class TeamPermissionOverrideDataServiceTests +{ + [Fact] + public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Dispatcher"); + context.UserPermissionOverrides.Add(new UserPermissionOverride + { + UserId = "u1", + PermissionKey = "deleteSites", + State = UserPermissionState.Deny + }); + await context.SaveChangesAsync(); + + var result = await new TeamPermissionOverrideDataService(context) + .GetUserAsync("u1", CancellationToken.None); + + result.Should().NotBeNull(); + result!.RoleName.Should().Be("Dispatcher"); + result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny); + } + + [Fact] + public async Task SetOverrideAsync_UpsertsOneCompositeKey() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Scheduler"); + await context.SaveChangesAsync(); + var service = new TeamPermissionOverrideDataService(context); + + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None); + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None); + + var rows = await context.UserPermissionOverrides.ToListAsync(); + rows.Should().ContainSingle(); + rows[0].State.Should().Be(UserPermissionState.Deny); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName) + { + context.Users.Add(new ApplicationUser + { + Id = userId, + UserName = userId, + NormalizedUserName = userId.ToUpperInvariant(), + Email = userId + "@example.com", + NormalizedEmail = (userId + "@example.com").ToUpperInvariant() + }); + context.Roles.Add(new IdentityRole + { + Id = "role-1", + Name = roleName, + NormalizedName = roleName.ToUpperInvariant() + }); + context.UserRoles.Add(new IdentityUserRole + { + UserId = userId, + RoleId = "role-1" + }); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs new file mode 100644 index 0000000..72704cc --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs @@ -0,0 +1,65 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +[Authorize] +[ApiController] +[Route("api/User/{userId}/Permissions")] +public sealed class TeamPermissionController : ControllerBase +{ + private readonly ITeamPermissionService _permissionService; + + public TeamPermissionController(ITeamPermissionService permissionService) + { + _permissionService = permissionService; + } + + [HttpGet] + public async Task GetProfile( + string userId, + CancellationToken cancellationToken) + { + var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken); + return ToActionResult(result); + } + + [HttpPut("{permissionKey}")] + public async Task SetOverride( + string userId, + string permissionKey, + [FromBody] SetTeamPermissionOverrideDTO request, + CancellationToken cancellationToken) + { + if (!Enum.IsDefined(request.State)) + return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." }); + + var result = await _permissionService.SetOverrideAsync( + userId, + permissionKey, + request.State, + User, + cancellationToken); + return ToActionResult(result); + } + + private static IActionResult ToActionResult( + TeamPermissionResult result) + { + return result.Status switch + { + TeamPermissionResultStatus.Success => new OkObjectResult(result.Value), + TeamPermissionResultStatus.Forbidden => new ForbidResult(), + TeamPermissionResultStatus.NotFound => new NotFoundObjectResult( + new Response { Status = "Error", Message = "User not found." }), + TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unknown permission key." }), + _ => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unable to update permissions." }) + }; + } +} diff --git a/SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs b/SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs new file mode 100644 index 0000000..d90a90f --- /dev/null +++ b/SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Dto; + +public sealed class TeamPermissionUserData +{ + public required string UserId { get; init; } + public string? RoleName { get; init; } + public IReadOnlyDictionary Overrides { get; init; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs new file mode 100644 index 0000000..954e8a4 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs @@ -0,0 +1,84 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation; + +public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideDataService +{ + private readonly ApplicationDbContext _context; + + public TeamPermissionOverrideDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetUserAsync( + string userId, + CancellationToken cancellationToken) + { + var userExists = await _context.Users + .AsNoTracking() + .AnyAsync(user => user.Id == userId, cancellationToken); + + if (!userExists) + return null; + + var roleName = await ( + from userRole in _context.UserRoles.AsNoTracking() + join role in _context.Roles.AsNoTracking() + on userRole.RoleId equals role.Id + where userRole.UserId == userId + select role.Name) + .FirstOrDefaultAsync(cancellationToken); + + var overrides = await _context.UserPermissionOverrides + .AsNoTracking() + .Where(permission => permission.UserId == userId) + .ToDictionaryAsync( + permission => permission.PermissionKey, + permission => permission.State, + StringComparer.OrdinalIgnoreCase, + cancellationToken); + + return new TeamPermissionUserData + { + UserId = userId, + RoleName = roleName, + Overrides = overrides + }; + } + + public async Task SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + CancellationToken cancellationToken) + { + var existing = await _context.UserPermissionOverrides + .SingleOrDefaultAsync( + permission => permission.UserId == userId + && permission.PermissionKey == permissionKey, + cancellationToken); + + if (existing is null) + { + await _context.UserPermissionOverrides.AddAsync( + new UserPermissionOverride + { + UserId = userId, + PermissionKey = permissionKey, + State = state + }, + cancellationToken); + } + else + { + existing.State = state; + } + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs new file mode 100644 index 0000000..49d57e2 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs @@ -0,0 +1,14 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Dto; + +namespace SeaHaven.DataServices.Interfaces; + +public interface ITeamPermissionOverrideDataService +{ + Task GetUserAsync(string userId, CancellationToken cancellationToken); + Task SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + CancellationToken cancellationToken); +} diff --git a/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs b/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs new file mode 100644 index 0000000..048c94b --- /dev/null +++ b/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs @@ -0,0 +1,130 @@ +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace SeaHaven.Services.Tests; + +public sealed class TeamPermissionServiceTests +{ + [Fact] + public async Task GetProfile_ReturnsEveryKeyAndRoleDefaults() + { + var data = new FakeDataService("u1", "Dispatcher"); + var result = await Service(data).GetProfileAsync("u1", Admin(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Permissions.Should().HaveCount(22); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue(); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeFalse(); + result.Value.Permissions.Should().OnlyContain(p => p.OverrideState == UserPermissionState.Unset); + } + + [Fact] + public async Task SetOverride_ChangesOnlyOnePermissionAndPersistsCanonicalKey() + { + var data = new FakeDataService("u1", "Dispatcher"); + var result = await Service(data).SetOverrideAsync( + "u1", "DELETEsites", UserPermissionState.Allow, Admin(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeTrue(); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).OverrideState.Should().Be(UserPermissionState.Allow); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue(); + data.LastSet.Should().Be(("u1", TeamPermissionKeys.DeleteSites, UserPermissionState.Allow)); + } + + [Fact] + public async Task SetOverride_RejectsUnknownKeyBeforeDataAccess() + { + var data = new FakeDataService("u1", "Dispatcher"); + var result = await Service(data).SetOverrideAsync( + "u1", "not-a-permission", UserPermissionState.Allow, Admin(), CancellationToken.None); + + result.Status.Should().Be(TeamPermissionResultStatus.InvalidPermissionKey); + data.GetCalls.Should().Be(0); + } + + [Fact] + public async Task NonAdminCannotReadOrWritePermissions() + { + var data = new FakeDataService("u1", "Dispatcher"); + var service = Service(data); + + var read = await service.GetProfileAsync("u1", User("Dispatcher"), CancellationToken.None); + var write = await service.SetOverrideAsync("u1", TeamPermissionKeys.CreateSites, UserPermissionState.Deny, User("Dispatcher"), CancellationToken.None); + + read.Status.Should().Be(TeamPermissionResultStatus.Forbidden); + write.Status.Should().Be(TeamPermissionResultStatus.Forbidden); + data.GetCalls.Should().Be(0); + } + + [Fact] + public async Task UnknownRoleReceivesNoGrantEvenWhenAnOverrideIsSet() + { + var data = new FakeDataService("u1", "Legacy"); + var result = await Service(data).SetOverrideAsync( + "u1", TeamPermissionKeys.CreateSites, UserPermissionState.Allow, Admin(), CancellationToken.None); + + result.Value!.Permissions.Should().OnlyContain(p => !p.IsGranted); + } + + [Fact] + public async Task MissingUserReturnsNotFound() + { + var data = new FakeDataService(null, null); + var result = await Service(data).GetProfileAsync("missing", Admin(), CancellationToken.None); + + result.Status.Should().Be(TeamPermissionResultStatus.NotFound); + } + + private static TeamPermissionService Service(FakeDataService data) => + new(data, new TeamPermissionPolicy()); + + private static ClaimsPrincipal Admin() => User("Admin"); + + private static ClaimsPrincipal User(string role) => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, role) }, "test")); + + private sealed class FakeDataService : ITeamPermissionOverrideDataService + { + private readonly string? _userId; + private readonly string? _roleName; + private readonly Dictionary _overrides = new(StringComparer.OrdinalIgnoreCase); + + public FakeDataService(string? userId, string? roleName) + { + _userId = userId; + _roleName = roleName; + } + + public int GetCalls { get; private set; } + public (string UserId, string PermissionKey, UserPermissionState State)? LastSet { get; private set; } + + public Task GetUserAsync(string userId, CancellationToken cancellationToken) + { + GetCalls++; + return Task.FromResult(_userId != userId + ? null + : new TeamPermissionUserData + { + UserId = userId, + RoleName = _roleName, + Overrides = new Dictionary(_overrides, StringComparer.OrdinalIgnoreCase) + }); + } + + public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken) + { + _overrides[permissionKey] = state; + LastSet = (userId, permissionKey, state); + return Task.CompletedTask; + } + } +} diff --git a/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs b/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs new file mode 100644 index 0000000..d930a83 --- /dev/null +++ b/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs @@ -0,0 +1,49 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs; + +public sealed class TeamPermissionProfileDTO +{ + public required string UserId { get; init; } + public string? RoleName { get; init; } + public required IReadOnlyList Permissions { get; init; } +} + +public sealed class TeamPermissionValueDTO +{ + public required string PermissionKey { get; init; } + public UserPermissionState OverrideState { get; init; } + public bool IsGranted { get; init; } +} + +public sealed class SetTeamPermissionOverrideDTO +{ + public UserPermissionState State { get; init; } +} + +public enum TeamPermissionResultStatus +{ + Success, + Forbidden, + NotFound, + InvalidPermissionKey +} + +public sealed class TeamPermissionResult +{ + private TeamPermissionResult(TeamPermissionResultStatus status, T? value) + { + Status = status; + Value = value; + } + + public TeamPermissionResultStatus Status { get; } + public T? Value { get; } + public bool IsSuccess => Status == TeamPermissionResultStatus.Success; + + public static TeamPermissionResult Success(T value) => + new(TeamPermissionResultStatus.Success, value); + + public static TeamPermissionResult Failure(TeamPermissionResultStatus status) => + new(status, default); +} diff --git a/SeaHaven.Services/Implementation/TeamPermissionService.cs b/SeaHaven.Services/Implementation/TeamPermissionService.cs new file mode 100644 index 0000000..34e6f26 --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamPermissionService.cs @@ -0,0 +1,96 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace SeaHaven.Services.Implementation; + +public sealed class TeamPermissionService : ITeamPermissionService +{ + private readonly ITeamPermissionOverrideDataService _dataService; + private readonly ITeamPermissionPolicy _policy; + + public TeamPermissionService( + ITeamPermissionOverrideDataService dataService, + ITeamPermissionPolicy policy) + { + _dataService = dataService; + _policy = policy; + } + + public async Task> GetProfileAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken) + { + if (!IsAdmin(caller)) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.Forbidden); + + var user = await _dataService.GetUserAsync(userId, cancellationToken); + return user is null + ? TeamPermissionResult.Failure(TeamPermissionResultStatus.NotFound) + : TeamPermissionResult.Success(BuildProfile(user)); + } + + public async Task> SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + ClaimsPrincipal caller, + CancellationToken cancellationToken) + { + if (!IsAdmin(caller)) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.Forbidden); + + var canonicalKey = TeamPermissionKeys.All.FirstOrDefault( + key => string.Equals(key, permissionKey, StringComparison.OrdinalIgnoreCase)); + if (canonicalKey is null) + return TeamPermissionResult.Failure( + TeamPermissionResultStatus.InvalidPermissionKey); + + var user = await _dataService.GetUserAsync(userId, cancellationToken); + if (user is null) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.NotFound); + + await _dataService.SetOverrideAsync(userId, canonicalKey, state, cancellationToken); + + var updatedOverrides = user.Overrides.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.OrdinalIgnoreCase); + updatedOverrides[canonicalKey] = state; + + return TeamPermissionResult.Success( + BuildProfile(new TeamPermissionUserData + { + UserId = user.UserId, + RoleName = user.RoleName, + Overrides = updatedOverrides + })); + } + + private TeamPermissionProfileDTO BuildProfile(TeamPermissionUserData user) + { + return new TeamPermissionProfileDTO + { + UserId = user.UserId, + RoleName = user.RoleName, + Permissions = TeamPermissionKeys.All + .Select(key => new TeamPermissionValueDTO + { + PermissionKey = key, + OverrideState = user.Overrides.TryGetValue(key, out var state) + ? state + : UserPermissionState.Unset, + IsGranted = _policy.IsAllowed(user.RoleName, key, user.Overrides) + }) + .ToList() + }; + } + + private static bool IsAdmin(ClaimsPrincipal? caller) => + caller?.IsInRole("Admin") == true; +} diff --git a/SeaHaven.Services/Interfaces/ITeamPermissionService.cs b/SeaHaven.Services/Interfaces/ITeamPermissionService.cs new file mode 100644 index 0000000..d4b3892 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamPermissionService.cs @@ -0,0 +1,20 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; +using System.Security.Claims; + +namespace SeaHaven.Services.Interfaces; + +public interface ITeamPermissionService +{ + Task> GetProfileAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken); + + Task> SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + ClaimsPrincipal caller, + CancellationToken cancellationToken); +} From 33f517620bc8dea84fac9aa9a1e0db4be50a60b3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:02:57 -0300 Subject: [PATCH 09/28] 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 d4afcced87623005bea79adf6c31331e01e2d6ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:01:42 -0300 Subject: [PATCH 10/28] feat(dashboard): add regional work order metrics (SH-348) --- .../DashboardServiceTests.cs | 23 +++++ .../Controllers/DashboardController.cs | 20 +++++ .../Implementation/DashboardDataService.cs | 3 +- .../Interfaces/IDashboardDataService.cs | 3 +- SeaHaven.Services/DTOs/DashboardDTOs.cs | 11 +++ SeaHaven.Services/Helpers/DashboardRegions.cs | 84 +++++++++++++++++++ .../Implementation/DashboardService.cs | 27 ++++++ .../Interfaces/IDashboardService.cs | 5 ++ 8 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 SeaHaven.Services/Helpers/DashboardRegions.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index ff65c94..c7c6410 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -301,4 +301,27 @@ public class DashboardServiceTests result.Items[0].AverageResolutionDays.Should().Be(3m); result.Items[0].Color.Should().Be("#123456"); } + + [Fact] + public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 1, State = "NY" }, + new Locations { Id = 2, State = "ca" }, + new Locations { Id = 3, State = "XX" }); + 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 = null }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetRegionsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + 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); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index aff03f3..bdce2e2 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -89,5 +89,25 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(ex.Message); } } + + [HttpGet("Regions")] + public async Task GetRegions( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 14b1ffa..a58aed3 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -70,7 +70,8 @@ namespace SeaHaven.DataServices.Implementation w.LegacyStatus ?? w.Status, w.ScheduledDate, w.OriginalDate, - w.CompletedDate)) + w.CompletedDate, + w.Locations == null ? null : w.Locations.State)) .ToListAsync(cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index bd3a394..fa794b1 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -31,7 +31,8 @@ namespace SeaHaven.DataServices.Interfaces string? LegacyStatus, DateTime? ScheduledDate, DateOnly? OriginalDate, - DateTime? CompletedDate); + DateTime? CompletedDate, + string? LocationState = null); public sealed record DashboardDispatcherWorkload( string DispatcherId, diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 6034544..e19f55c 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -40,6 +40,17 @@ namespace SeaHaven.Services.DTOs public decimal? AverageResolutionDays { get; init; } } + public sealed class DashboardRegionResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + } + + public sealed class DashboardRegionRowDTO + { + public string Region { get; init; } = string.Empty; + public int WorkOrderCount { get; init; } + } + public class DashboardStatsDTO { public int Total { get; set; } diff --git a/SeaHaven.Services/Helpers/DashboardRegions.cs b/SeaHaven.Services/Helpers/DashboardRegions.cs new file mode 100644 index 0000000..f3b5fae --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardRegions.cs @@ -0,0 +1,84 @@ +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 string Resolve(string? state) + { + if (string.IsNullOrWhiteSpace(state)) + return "Unmapped/Other"; + + return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region) + ? region + : "Unmapped/Other"; + } + + public static IReadOnlyDictionary Count( + IReadOnlyList workOrders) + { + var counts = Names.ToDictionary(name => name, _ => 0, StringComparer.Ordinal); + foreach (var workOrder in workOrders) + counts[Resolve(workOrder.LocationState)]++; + + 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/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index aaad3f1..2bbe7ca 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -152,5 +152,32 @@ namespace SeaHaven.Services.Implementation TotalDispatchers = rows.Count }; } + + public async Task GetRegionsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + 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 workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var counts = DashboardRegions.Count(workOrders); + return new DashboardRegionResponseDTO + { + Items = DashboardRegions.Names + .Select(region => new DashboardRegionRowDTO + { + Region = region, + WorkOrderCount = counts[region] + }) + .ToList() + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 8b74dd2..9b10f73 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -21,5 +21,10 @@ namespace SeaHaven.Services.Interfaces DashboardStatsQueryDTO query, int page, CancellationToken cancellationToken); + + Task GetRegionsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + CancellationToken cancellationToken); } } From 3358ea5058707d08386e97bfe6dcc04877d42316 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:10:42 -0300 Subject: [PATCH 11/28] feat(dashboard): add vendor insights (SH-349) --- .../DashboardServiceTests.cs | 43 ++++++++++++++++ .../Controllers/DashboardController.cs | 18 +++++++ .../Implementation/DashboardDataService.cs | 15 +++++- .../Interfaces/IDashboardDataService.cs | 7 ++- SeaHaven.Services/DTOs/DashboardDTOs.cs | 18 +++++++ .../Implementation/DashboardService.cs | 49 +++++++++++++++++++ .../Interfaces/IDashboardService.cs | 4 ++ 7 files changed, 152 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index c7c6410..a383bd7 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -324,4 +324,47 @@ public class DashboardServiceTests "East", "Central", "West", "California", "Unmapped/Other"); result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2); } + + [Fact] + public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics() + { + await using var ctx = NewContext(); + ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false }); + ctx.Vendors.Add(new Vendor + { + Id = 20, + CompanyId = 10, + IsActive = true, + CompanyName = "Acme Services" + }); + ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1), + RescheduleCount = 1 + }, + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-2), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1) + }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetVendorInsightsAsync( + AccountUser(1), CancellationToken.None); + + result.TotalVendors.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].TotalJobs.Should().Be(2); + result.Items[0].CompletionRate.Should().Be(100); + result.Items[0].RescheduleRate.Should().Be(50); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index bdce2e2..055b933 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -109,5 +109,23 @@ namespace Api.SeaHavenIndustries.Controllers }); } } + + [HttpGet("VendorInsights")] + public async Task GetVendorInsights(CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index a58aed3..a56f74f 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -71,7 +71,20 @@ namespace SeaHaven.DataServices.Implementation w.ScheduledDate, w.OriginalDate, w.CompletedDate, - w.Locations == null ? null : w.Locations.State)) + w.Locations == null ? null : w.Locations.State, + w.RescheduleCount, + w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null + ? null + : w.PrimaryDispatch.Vendor.CompanyId, + w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null + || w.PrimaryDispatch.Vendor.Company == null + ? null + : w.PrimaryDispatch.Vendor.Company.Name, + w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + && w.PrimaryDispatch.Vendor.IsActive, + w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + && w.PrimaryDispatch.Vendor.Company != null + && w.PrimaryDispatch.Vendor.Company.IsDeleted == true)) .ToListAsync(cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index fa794b1..5348703 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -32,7 +32,12 @@ namespace SeaHaven.DataServices.Interfaces DateTime? ScheduledDate, DateOnly? OriginalDate, DateTime? CompletedDate, - string? LocationState = null); + string? LocationState = null, + int RescheduleCount = 0, + int? VendorCompanyId = null, + string? VendorCompanyName = null, + bool VendorIsActive = false, + bool VendorCompanyIsDeleted = false); public sealed record DashboardDispatcherWorkload( string DispatcherId, diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index e19f55c..a767a03 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -51,6 +51,24 @@ namespace SeaHaven.Services.DTOs public int WorkOrderCount { get; init; } } + public sealed class DashboardVendorInsightsResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalVendors { get; init; } + } + + public sealed class DashboardVendorInsightsRowDTO + { + public int VendorCompanyId { get; init; } + public string VendorCompanyName { get; init; } = string.Empty; + public decimal CompletionRate { get; init; } + public decimal RescheduleRate { get; init; } + public decimal? AverageResolutionDays { get; init; } + public int TotalJobs { get; init; } + } + public class DashboardStatsDTO { public int Total { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 2bbe7ca..757dfb3 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -179,5 +179,54 @@ namespace SeaHaven.Services.Implementation .ToList() }; } + + public async Task GetVendorInsightsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, null, null, null, cancellationToken); + var activeVendorOrders = workOrders + .Where(workOrder => workOrder.VendorCompanyId.HasValue + && workOrder.VendorIsActive + && !workOrder.VendorCompanyIsDeleted) + .ToList(); + var today = DashboardBusinessTime.Today(); + var rows = activeVendorOrders + .GroupBy(workOrder => new + { + Id = workOrder.VendorCompanyId!.Value, + Name = workOrder.VendorCompanyName ?? string.Empty + }) + .Select(group => + { + var orders = group.ToList(); + var metrics = DashboardMetrics.Calculate(orders, today); + var rescheduled = orders.Count(order => order.RescheduleCount >= 1); + return new DashboardVendorInsightsRowDTO + { + VendorCompanyId = group.Key.Id, + VendorCompanyName = group.Key.Name, + CompletionRate = metrics.CompletionRate, + RescheduleRate = Math.Round((decimal)rescheduled / orders.Count * 100, 2), + AverageResolutionDays = metrics.AverageResolutionDays, + TotalJobs = orders.Count + }; + }) + .OrderByDescending(row => row.CompletionRate) + .ThenBy(row => row.VendorCompanyName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.VendorCompanyId) + .ToList(); + const int pageSize = 10; + + return new DashboardVendorInsightsResponseDTO + { + Items = rows.Take(pageSize).ToList(), + Page = 1, + PageSize = pageSize, + TotalVendors = rows.Count + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 9b10f73..2f744eb 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -26,5 +26,9 @@ namespace SeaHaven.Services.Interfaces ClaimsPrincipal user, DashboardStatsQueryDTO query, CancellationToken cancellationToken); + + Task GetVendorInsightsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken); } } From 9d7efa34a6241227b852f47329264b08576c36ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:42:26 -0300 Subject: [PATCH 12/28] feat(dashboard): add trend metrics (SH-350) --- .../DashboardServiceTests.cs | 255 ++++++++++++++++++ .../Controllers/DashboardController.cs | 41 +++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 27 +- .../Implementation/DashboardService.cs | 171 ++++++++++++ .../Interfaces/IDashboardService.cs | 5 + 5 files changed, 498 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index a383bd7..54b8886 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -367,4 +367,259 @@ public class DashboardServiceTests result.Items[0].CompletionRate.Should().Be(100); result.Items[0].RescheduleRate.Should().Be(50); } + + [Fact] + public async Task GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled, + ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 1, 14), + DateTo = new DateOnly(2026, 1, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("day"); + trend.Buckets.Select(bucket => bucket.Date).Should().Equal( + new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16)); + trend.Buckets.Select(bucket => bucket.Label).Should().Equal( + "2026-01-14", "2026-01-15", "2026-01-16"); + + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Canceled.Should().Be(1); + trend.Buckets[0].Completed.Should().Be(0); + trend.Buckets[0].Overdue.Should().Be(1); + + trend.Buckets[1].Total.Should().Be(2); + trend.Buckets[1].Open.Should().Be(1); + trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Overdue.Should().Be(1); + + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Open.Should().Be(1); + trend.Buckets[2].Overdue.Should().Be(1); + + trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); + } + + [Fact] + public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + Range = "3m", + DateFrom = new DateOnly(2026, 6, 17), + DateTo = new DateOnly(2026, 9, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("week"); + trend.Buckets.Should().HaveCount(14); + trend.Buckets.Select(bucket => bucket.Date.DayOfWeek) + .Should().OnlyContain(day => day == DayOfWeek.Monday); + trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15)); + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(2); + trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14)); + trend.Buckets[^1].Total.Should().Be(1); + trend.Buckets[^1].Completed.Should().Be(1); + } + + [Fact] + public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None); + + trend.Granularity.Should().Be("month"); + trend.Buckets.Should().HaveCount(12); + trend.Buckets[0].Label.Should().Be("2026-01-01"); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[2].Label.Should().Be("2026-03-01"); + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Completed.Should().Be(1); + trend.Buckets[11].Label.Should().Be("2026-12-01"); + trend.Buckets[11].Total.Should().Be(1); + trend.Buckets[11].Open.Should().Be(1); + trend.Buckets[11].Overdue.Should().Be(0); + trend.Today.Should().Be(DashboardBusinessTime.Today()); + } + + [Fact] + public async Task GetTrendAsync_MarksTodayBucketAsCurrent() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0)) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-2), + DateTo = today.AddDays(2) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Today.Should().Be(today); + trend.Buckets.Should().HaveCount(5); + trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1); + trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-2", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 2, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 2, 2), + DateTo = new DateOnly(2026, 2, 3) + }; + + var dispatcherTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + dispatcherTrend.Buckets.Should().HaveCount(2); + dispatcherTrend.Buckets[0].Total.Should().Be(1); + dispatcherTrend.Buckets[1].Total.Should().Be(0); + + var adminTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + adminTrend.Buckets[0].Total.Should().Be(2); + + var otherAccountTrend = await NewService(ctx).GetTrendAsync( + AccountUser(2), query, CancellationToken.None); + + otherAccountTrend.Buckets[0].Total.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 055b933..5551a5d 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -110,6 +110,47 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("Trend")] + public async Task GetTrend( + [FromQuery] DashboardTrendQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken); + + return Ok(new + { + granularity = trend.Granularity, + today = trend.Today, + buckets = trend.Buckets.Select(bucket => new + { + date = bucket.Date, + label = bucket.Label, + total = bucket.Total, + open = bucket.Open, + completed = bucket.Completed, + canceled = bucket.Canceled, + overdue = bucket.Overdue, + isCurrent = bucket.IsCurrent + }) + }); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + [HttpGet("VendorInsights")] public async Task GetVendorInsights(CancellationToken cancellationToken = default) { diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index a767a03..ef5c70d 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -1,11 +1,36 @@ namespace SeaHaven.Services.DTOs { - public sealed class DashboardStatsQueryDTO + public class DashboardStatsQueryDTO { public DateOnly? DateFrom { get; set; } public DateOnly? DateTo { get; set; } } + public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO + { + public string? Range { get; set; } + public int? Year { get; set; } + } + + public sealed class DashboardTrendResponseDTO + { + public string Granularity { get; init; } = "day"; + public DateOnly Today { get; init; } + public IReadOnlyList Buckets { get; init; } = []; + } + + public sealed class DashboardTrendBucketDTO + { + public DateOnly Date { get; init; } + public string Label { get; init; } = string.Empty; + public int Total { get; init; } + public int Open { get; init; } + public int Completed { get; init; } + public int Canceled { get; init; } + public int Overdue { get; init; } + public bool IsCurrent { get; init; } + } + public sealed class DashboardWorkloadResponseDTO { public IReadOnlyList Items { get; init; } = []; diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 757dfb3..a4bdfce 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Security.Claims; +using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -180,6 +182,175 @@ namespace SeaHaven.Services.Implementation }; } + public async Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO query, + 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 today = DashboardBusinessTime.Today(); + var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); + var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId); + var firstBucketStart = BucketStart(windowFrom, granularity); + var lastBucketStart = BucketStart(windowTo, granularity); + var lastBucketEnd = BucketEnd(lastBucketStart, granularity); + + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, + dispatcherId, + firstBucketStart.AddDays(-1), + lastBucketEnd.AddDays(1), + cancellationToken); + + var buckets = BuildBucketStarts(firstBucketStart, windowTo, granularity) + .Select(start => new TrendBucket(start)) + .ToList(); + var bucketStarts = buckets.Select(bucket => bucket.Start).ToList(); + + foreach (var workOrder in workOrders) + { + if (workOrder.ScheduledDate is not DateTime scheduled) + continue; + var businessDate = DateOnly.FromDateTime( + TimeZoneInfo.ConvertTimeFromUtc( + DateTime.SpecifyKind(scheduled, DateTimeKind.Utc), timezone)); + if (businessDate < firstBucketStart || businessDate > lastBucketEnd) + continue; + var bucket = buckets[LocateBucket(bucketStarts, businessDate)]; + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + bucket.Total++; + if (status == LifecycleStatus.Completed) + bucket.Completed++; + else if (status == LifecycleStatus.Canceled) + bucket.Canceled++; + else + { + bucket.Open++; + if (businessDate < today) + bucket.Overdue++; + } + } + + DateOnly? currentStart = today < firstBucketStart || today > lastBucketEnd + ? null + : buckets[LocateBucket(bucketStarts, today)].Start; + + return new DashboardTrendResponseDTO + { + Granularity = granularity, + Today = today, + Buckets = buckets.Select(bucket => new DashboardTrendBucketDTO + { + Date = bucket.Start, + Label = bucket.Start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Total = bucket.Total, + Open = bucket.Open, + Completed = bucket.Completed, + Canceled = bucket.Canceled, + Overdue = bucket.Overdue, + IsCurrent = bucket.Start == currentStart + }).ToList() + }; + } + + private static (DateOnly From, DateOnly To, string Granularity) ResolveTrendWindow( + DashboardTrendQueryDTO query, + DateOnly today) + { + if (query.Year is int year) + { + if (year < DateOnly.MinValue.Year || year > DateOnly.MaxValue.Year) + throw new ArgumentOutOfRangeException(nameof(query)); + return (new DateOnly(year, 1, 1), new DateOnly(year, 12, 31), "month"); + } + + if (string.Equals(query.Range, ThreeMonthRange, StringComparison.OrdinalIgnoreCase)) + { + var weeklyTo = query.DateTo ?? today; + var weeklyFrom = query.DateFrom ?? weeklyTo.AddMonths(-3).AddDays(1); + if (weeklyFrom > weeklyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (weeklyFrom, weeklyTo, "week"); + } + + if (query.DateFrom is DateOnly dailyFrom && query.DateTo is DateOnly dailyTo) + { + if (dailyFrom > dailyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (dailyFrom, dailyTo, "day"); + } + + if (query.DateFrom.HasValue != query.DateTo.HasValue) + throw new ArgumentOutOfRangeException(nameof(query)); + + return (today, today, "day"); + } + + private static List BuildBucketStarts(DateOnly from, DateOnly to, string granularity) + { + var starts = new List(); + var current = BucketStart(from, granularity); + while (current <= to) + { + starts.Add(current); + current = granularity switch + { + "week" => current.AddDays(7), + "month" => current.AddMonths(1), + _ => current.AddDays(1) + }; + } + return starts; + } + + private static DateOnly BucketStart(DateOnly date, string granularity) => granularity switch + { + "week" => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)), + "month" => new DateOnly(date.Year, date.Month, 1), + _ => date + }; + + private static DateOnly BucketEnd(DateOnly start, string granularity) => granularity switch + { + "week" => start.AddDays(6), + "month" => start.AddMonths(1).AddDays(-1), + _ => start + }; + + private static int LocateBucket(List starts, DateOnly date) + { + var low = 0; + var high = starts.Count - 1; + while (low < high) + { + var middle = (low + high + 1) / 2; + if (starts[middle] <= date) + low = middle; + else + high = middle - 1; + } + return low; + } + + private const string ThreeMonthRange = "3m"; + + private sealed class TrendBucket(DateOnly start) + { + public DateOnly Start { get; } = start; + public int Total { get; set; } + public int Open { get; set; } + public int Completed { get; set; } + public int Canceled { get; set; } + public int Overdue { get; set; } + } + public async Task GetVendorInsightsAsync( ClaimsPrincipal user, CancellationToken cancellationToken) diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 2f744eb..8464bad 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -27,6 +27,11 @@ namespace SeaHaven.Services.Interfaces DashboardStatsQueryDTO query, CancellationToken cancellationToken); + Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO query, + CancellationToken cancellationToken); + Task GetVendorInsightsAsync( ClaimsPrincipal user, CancellationToken cancellationToken); From 7947e2e77defb6807bcce82f4769cb9e29192c9a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 19:19:10 -0300 Subject: [PATCH 13/28] 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 5877f7817f12ca86df1cc556f97979f777ce81f8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 19:57:09 -0300 Subject: [PATCH 14/28] fix(dashboard): bucket trend by calendar ScheduledDate (SH-350) ScheduledDate is persisted as a midnight calendar value (board create writes request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and converted to America/New_York, so midnight became 19:00/20:00 the previous day and every board-scheduled work order fell into the prior bucket: a job scheduled today read as yesterday and overdue, and the Today bucket showed zero. Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the same rows. Rewrite the daily and yearly trend tests to assert calendar-date classification (the removed conversion had encoded the shift into their expectations) and add a regression test that a midnight-today work order stays in the Today bucket and is not overdue. --- .../DashboardServiceTests.cs | 57 ++++++++++++++++--- .../Implementation/DashboardService.cs | 5 +- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 54b8886..de6a9a6 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -369,9 +369,14 @@ public class DashboardServiceTests } [Fact] - public async Task GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate() + public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() { using var ctx = NewContext(); + // ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work + // order buckets on its own calendar day regardless of the time-of-day component. The + // UTC hours below were previously shifted a full day by an America/New_York conversion + // (03:00 UTC -> prior evening), so they double as a regression guard: each row must now + // stay on the calendar date it was scheduled for. ctx.workOrders.AddRange( new WorkOrder { @@ -426,17 +431,21 @@ public class DashboardServiceTests trend.Buckets.Select(bucket => bucket.Label).Should().Equal( "2026-01-14", "2026-01-15", "2026-01-16"); - trend.Buckets[0].Total.Should().Be(2); - trend.Buckets[0].Open.Should().Be(1); + // 01-14: only the canceled row scheduled that calendar day. + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(0); trend.Buckets[0].Canceled.Should().Be(1); trend.Buckets[0].Completed.Should().Be(0); - trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(0); + // 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row. trend.Buckets[1].Total.Should().Be(2); trend.Buckets[1].Open.Should().Be(1); trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Canceled.Should().Be(0); trend.Buckets[1].Overdue.Should().Be(1); + // 01-16: the 03:00 UTC scheduled row that stays on its own day. trend.Buckets[2].Total.Should().Be(1); trend.Buckets[2].Open.Should().Be(1); trend.Buckets[2].Overdue.Should().Be(1); @@ -444,6 +453,38 @@ public class DashboardServiceTests trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); } + [Fact] + public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + // A board-scheduled work order for today is stored as midnight (`.Date`). It must land + // in the Today bucket and count as open, not roll back to yesterday and read as overdue. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-1), + DateTo = today.AddDays(1) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today); + todayBucket.Total.Should().Be(1); + todayBucket.Open.Should().Be(1); + todayBucket.Overdue.Should().Be(0); + + var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1)); + yesterdayBucket.Total.Should().Be(0); + } + [Fact] public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() { @@ -515,6 +556,8 @@ public class DashboardServiceTests LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) }, + // 2027-01-01 by calendar: outside the 2026 window. Previously an + // America/New_York conversion pulled it back to 2026-12-31 and into December. new WorkOrder { AccountId = 1, @@ -535,10 +578,10 @@ public class DashboardServiceTests trend.Buckets[2].Label.Should().Be("2026-03-01"); trend.Buckets[2].Total.Should().Be(1); trend.Buckets[2].Completed.Should().Be(1); + // December stays empty: the 2027-01-01 row is no longer pulled inside the window. trend.Buckets[11].Label.Should().Be("2026-12-01"); - trend.Buckets[11].Total.Should().Be(1); - trend.Buckets[11].Open.Should().Be(1); - trend.Buckets[11].Overdue.Should().Be(0); + trend.Buckets[11].Total.Should().Be(0); + trend.Buckets[11].Open.Should().Be(0); trend.Today.Should().Be(DashboardBusinessTime.Today()); } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index a4bdfce..56b4c8a 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -196,7 +196,6 @@ namespace SeaHaven.Services.Implementation var today = DashboardBusinessTime.Today(); var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); - var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId); var firstBucketStart = BucketStart(windowFrom, granularity); var lastBucketStart = BucketStart(windowTo, granularity); var lastBucketEnd = BucketEnd(lastBucketStart, granularity); @@ -217,9 +216,7 @@ namespace SeaHaven.Services.Implementation { if (workOrder.ScheduledDate is not DateTime scheduled) continue; - var businessDate = DateOnly.FromDateTime( - TimeZoneInfo.ConvertTimeFromUtc( - DateTime.SpecifyKind(scheduled, DateTimeKind.Utc), timezone)); + var businessDate = DateOnly.FromDateTime(scheduled.Date); if (businessDate < firstBucketStart || businessDate > lastBucketEnd) continue; var bucket = buckets[LocateBucket(bucketStarts, businessDate)]; From 104439c23984fd4c9737bd979607fc2183a21f72 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:07:31 -0300 Subject: [PATCH 15/28] 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 22308f21f6a4c590a97606595daad36d3c5bfab3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:15:20 -0300 Subject: [PATCH 16/28] 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 7bdcc61651c8977ac3e4c26ee93aa2ed0615367f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:15:30 -0300 Subject: [PATCH 17/28] fix(work-orders): bind GetWorkorderById id from the query string (SH-374) --- .../WorkOrderGetByIdBindingTests.cs | 106 ++++++++++++++++++ .../WorkOrderRouteContractTests.cs | 6 +- .../Controllers/WorkOrderController.cs | 10 +- 3 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs new file mode 100644 index 0000000..2d88138 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs @@ -0,0 +1,106 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while +/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order, +/// and an unknown id keeps the existing "ID not found!" response. +/// +public class WorkOrderGetByIdBindingTests +{ + private const int ExistingId = 374; + private const int UnknownId = 999_999; + + private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService().ActionDescriptors.Items + .OfType() + .Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController)) + .Where(cad => cad.ActionConstraints! + .OfType() + .Any(c => c.HttpMethods.Contains("GET"))) + .Single(cad => string.Equals( + cad.AttributeRouteInfo?.Template?.Trim('/'), + $"api/WorkOrder/{relativeTemplate}", + StringComparison.Ordinal)); + } + + [Theory] + [InlineData("GetWorkorderById", "Query")] + [InlineData("{id:int}", "Path")] + public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource) + { + var action = ActionForTemplate(relativeTemplate); + + var id = action.Parameters.Single(p => p.Name == "id"); + id.BindingInfo.Should().NotBeNull(); + id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource); + } + + private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController() + { + var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" }; + var service = new Mock(); + service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny())).ReturnsAsync(existing); + service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny())) + .ReturnsAsync((WorkOrderDetailReadModel?)null); + + var controller = new WorkOrderController( + service.Object, + Mock.Of(), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + return (controller, existing); + } + + [Fact] + public async Task Existing_id_returns_the_same_work_order_through_both_routes() + { + var (controller, existing) = NewController(); + + var byQuery = await controller.GetWorkorderById(ExistingId); + var byRoute = await controller.GetWorkorderByRouteId(ExistingId); + + byQuery.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + byRoute.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + } + + [Fact] + public async Task Unknown_id_keeps_the_not_found_response_through_both_routes() + { + var (controller, _) = NewController(); + + foreach (var result in new[] + { + await controller.GetWorkorderById(UnknownId), + await controller.GetWorkorderByRouteId(UnknownId), + }) + { + var body = result.Should().BeOfType().Which.Value + .Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Be("ID not found!"); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 6411337..1bb17e5 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests; /// combination is registered more than once by different actions. /// /// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the -/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, -/// GetWorkorderById) and the two shared controller-level base routes. +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder) +/// and the two shared controller-level base routes. /// public class WorkOrderRouteContractTests { @@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes - /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). + /// come from 51 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index c7928de..9c73034 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpGet("{id:int}")] + // SH-374: two actions, one per route, so each binds id from the source its route provides. + // A single action carrying both routes inferred id as [FromRoute] and the query route got 0. [HttpGet("GetWorkorderById")] - public async Task GetWorkorderById(int id) + public Task GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id); + + [HttpGet("{id:int}")] + public Task GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id); + + private async Task GetWorkorderDetail(int id) { try { From 93ecbbd410a67c280c5c47a222f5360ef3b1e94f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:25:45 -0300 Subject: [PATCH 18/28] 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 af441894d7777409c5be701ed0cb49bc8155aa70 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:27:26 -0300 Subject: [PATCH 19/28] fix(permissions): clean up overrides on user delete and converge concurrent override writes (SH-328) DeleteUserWithCascadeAsync never removed UserPermissionOverrides rows, and the FK on UserId is Restrict. Once an override was saved for a member, the admin hard-delete (DeleteUserAsync -> DeleteUserWithCascadeAsync) failed the foreign key inside the transaction and the controller surfaced it as a 400, so the user was never deleted. Add an explicit ExecuteDelete on UserPermissionOverrides before the user is removed, matching how UserRoles is already cleared in the same method (no schema change). SetOverrideAsync was check-then-insert on the composite key with no DbUpdateException handling, so two concurrent PUTs for the same (UserId, PermissionKey) let the loser violate PK_UserPermissionOverrides and return 500. Catch the conflict, detach the pending insert, and converge by updating the persisted row to the caller's requested state. --- .../TeamPermissionOverrideDataService.cs | 43 +++++++++++++------ .../Implementation/UserDataService.cs | 4 ++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs index 954e8a4..b968a8b 100644 --- a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs +++ b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs @@ -63,22 +63,39 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD && permission.PermissionKey == permissionKey, cancellationToken); - if (existing is null) - { - await _context.UserPermissionOverrides.AddAsync( - new UserPermissionOverride - { - UserId = userId, - PermissionKey = permissionKey, - State = state - }, - cancellationToken); - } - else + if (existing is not null) { existing.State = state; + await _context.SaveChangesAsync(cancellationToken); + return; } - await _context.SaveChangesAsync(cancellationToken); + var addition = new UserPermissionOverride + { + UserId = userId, + PermissionKey = permissionKey, + State = state + }; + await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken); + + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // A concurrent PUT inserted the same (UserId, PermissionKey) between our + // existence check and this save, violating PK_UserPermissionOverrides. + // Converge on the caller's intent by updating the persisted row. + _context.Entry(addition).State = EntityState.Detached; + + var winner = await _context.UserPermissionOverrides + .SingleAsync( + permission => permission.UserId == userId + && permission.PermissionKey == permissionKey, + cancellationToken); + winner.State = state; + await _context.SaveChangesAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 055f870..f5dbb70 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -135,6 +135,10 @@ namespace SeaHaven.DataServices.Implementation .Where(role => role.UserId == id) .ExecuteDeleteAsync(cancellationToken); + await _context.UserPermissionOverrides + .Where(permissionOverride => permissionOverride.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + await _context.Users .Where(existingUser => existingUser.Id == id) .ExecuteDeleteAsync(cancellationToken); From 660ebac628ca1ab7b76ba57fd534b7504764225e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:45:15 -0300 Subject: [PATCH 20/28] 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 60e006e8d7ab8e04cdf0adac2b5c434580741a0b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:17:59 -0300 Subject: [PATCH 21/28] 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 22/28] 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 23/28] 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 24/28] 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 25/28] 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 26/28] 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 27/28] 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 28/28] 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();