From 06f9450485c2aa10a3068fd3d295f86f782bed0e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 16:18:46 -0300 Subject: [PATCH 1/3] feat(services): add global services registry --- .../ServicesRegistryControllerTests.cs | 185 + .../ServicesRegistryServiceTests.cs | 364 ++ .../Controllers/ServicesRegistryController.cs | 110 + .../Auth/ApplicationDbContext.cs | 49 + ...6185443_SH303_ServicesRegistry.Designer.cs | 4074 +++++++++++++++++ .../20260916185443_SH303_ServicesRegistry.cs | 162 + .../ApplicationDbContextModelSnapshot.cs | 141 + Data.SeaHavenIndustries/Models/Service.cs | 29 + .../ServicesRegistryDataService.cs | 130 + .../IServicesRegistryDataService.cs | 39 + .../DTOs/ServicesRegistryDTOs.cs | 38 + .../Exceptions/ServicesRegistryExceptions.cs | 12 + .../Implementation/ServicesRegistryService.cs | 226 + .../Interfaces/IServicesRegistryService.cs | 19 + .../SH303ServicesRegistryMigrationTests.cs | 316 ++ 15 files changed, 5894 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.cs create mode 100644 Data.SeaHavenIndustries/Models/Service.cs create mode 100644 SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs create mode 100644 SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs create mode 100644 SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs create mode 100644 SeaHaven.Services/Implementation/ServicesRegistryService.cs create mode 100644 SeaHaven.Services/Interfaces/IServicesRegistryService.cs create mode 100644 SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs new file mode 100644 index 0000000..c1d6cf0 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs @@ -0,0 +1,185 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class ServicesRegistryControllerTests +{ + private static ServicesRegistryController NewController(Mock service, params string[] roles) + { + var controller = new ServicesRegistryController(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")) + } + } + }; + return controller; + } + + private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) => + (result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto; + + [Fact] + public async Task GetAll_ReturnsOkWithServices() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Name = "Leak", IsActive = true } }); + + var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as IEnumerable).Should().ContainSingle(s => s.Name == "Leak"); + } + + [Fact] + public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny())) + .Throws(new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency.")); + + var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid"); + ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency."); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFoundWithCode() + { + var service = new Mock(); + service.Setup(s => s.GetByIdAsync(9, It.IsAny())) + .ReturnsAsync((ServiceDto?)null); + + var result = await NewController(service, "Admin").GetById(9, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Create_PassesCallerAndReturnsCreated() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true }); + + var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var created = result.Should().BeOfType().Subject; + created.StatusCode.Should().Be(StatusCodes.Status201Created); + (created.Value as ServiceDto)!.Id.Should().Be(42); + service.Verify(s => s.CreateAsync( + It.Is(p => p.IsInRole("Scheduler")), + It.Is(i => i.Name == "Leak"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.")); + + var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenDuplicateName_Returns400WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.")); + + var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_ReturnsOkWithUpdatedService() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 7, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" }); + + var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as ServiceDto)!.Name.Should().Be("Renamed"); + } + + [Fact] + public async Task Update_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 404, It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Deactivate_ReturnsOk() + { + var service = new Mock(); + + var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(s => s.DeactivateAsync( + It.Is(p => p.IsInRole("Admin")), 3, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Deactivate_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 3, It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.")); + + var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Deactivate_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 404, It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None); + + result.Should().BeOfType(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs new file mode 100644 index 0000000..6d7086d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs @@ -0,0 +1,364 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Global Services registry behavior. The registry has no tenant scope +// (no AccountId), authorization is enforced at the service boundary, and the +// only allowed supported work-order types are PM, Reactive, and Emergency. +public class ServicesRegistryServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ServicesRegistryService NewService(ApplicationDbContext ctx) => + new(new ServicesRegistryDataService(ctx)); + + private static ClaimsPrincipal Principal(params string[] roles) => + new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")); + + private static ClaimsPrincipal Admin() => Principal("Admin"); + private static ClaimsPrincipal Scheduler() => Principal("Scheduler"); + + private static ServiceInput Input( + string name = "Leak", + string? trade = "Plumbing & Water Systems", + string? iconKey = "plumbing-water-systems", + bool requiresDoc = false, + int? templateId = null, + List? types = null) => new() + { + Name = name, + Trade = trade, + IconKey = iconKey, + RequiresCompletionDocument = requiresDoc, + CompletionDocTemplateId = templateId, + SupportedWorkOrderTypes = types ?? new List + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + } + }; + + private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) => + new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted }; + + [Fact] + public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes() + { + using var ctx = NewContext(); + + var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None); + + dto.Id.Should().BeGreaterThan(0); + dto.Name.Should().Be("Leak Detection"); + dto.Trade.Should().Be("Plumbing & Water Systems"); + dto.IconKey.Should().Be("plumbing-water-systems"); + dto.IsActive.Should().BeTrue("new services are always active"); + dto.CompletionDocTemplate.Should().BeNull(); + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }); + + var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single(); + row.NormalizedName.Should().Be("leak detection"); + } + + [Fact] + public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes() + { + using var ctx = NewContext(); + + var input = Input(types: null); + input.SupportedWorkOrderTypes = null; + + var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None); + var ex = (await act.Should().ThrowAsync()).Subject.Single(); + ex.Code.Should().Be("NameRequired"); + } + + [Fact] + public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("TradeInvalid"); + } + + [Theory] + [InlineData(" hvac ")] + [InlineData("HVAC")] + [InlineData("hvac")] + public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade) + { + using var ctx = NewContext(); + var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None); + dto.Trade.Should().Be("HVAC"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("IconKeyInvalid"); + } + + [Fact] + public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing() + { + using var ctx = NewContext(); + var service = NewService(ctx); + await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter() + { + using var ctx = NewContext(); + var service = NewService(ctx); + // Distinct actors with different role sets still share one global registry. + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + + var all = await service.GetAllAsync(null, null, CancellationToken.None); + all.Should().ContainSingle(s => s.Id == first.Id); + typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions"); + } + + [Fact] + public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid() + { + using var ctx = NewContext(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + ctx.CompletionDocTemplates.Add(Template("Inactive", active: false)); + ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true)); + ctx.SaveChanges(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + } + + [Fact] + public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeTrue(); + dto.CompletionDocTemplate.Should().NotBeNull(); + dto.CompletionDocTemplate!.Id.Should().Be(template.Id); + dto.CompletionDocTemplate.Name.Should().Be("Completion Packet"); + } + + [Fact] + public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeFalse(); + dto.CompletionDocTemplate.Should().BeNull(); + ctx.Services.Single().CompletionDocTemplateId.Should().BeNull(); + } + + [Theory] + [InlineData(WorkOrderType.PO)] + [InlineData(WorkOrderType.Project)] + [InlineData(WorkOrderType.Inspection)] + [InlineData(WorkOrderType.AddOn)] + [InlineData(WorkOrderType.Other)] + public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported) + { + using var ctx = NewContext(); + var input = Input(types: new List { WorkOrderType.PM, unsupported }); + var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task Update_RewritesFieldsButPreservesActiveState() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var updated = await service.UpdateAsync( + Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None); + + updated.Name.Should().Be("Leak Repair"); + updated.Trade.Should().Be("HVAC"); + updated.IconKey.Should().Be("hvac"); + updated.IsActive.Should().BeFalse("update preserves the persisted active state"); + } + + [Fact] + public async Task Update_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None); + + var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().Name.Should().Be("Leak"); + } + + [Fact] + public async Task Deactivate_SoftStateChangeOnly_PreservesRow() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + ctx.Services.Should().HaveCount(1, "deactivation never hard deletes"); + var byId = await service.GetByIdAsync(created.Id, CancellationToken.None); + byId.Should().NotBeNull(); + byId!.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Deactivate_WhenScheduler_ThrowsForbidden() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Deactivate_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task GetAll_FiltersByActiveStateAndWorkOrderType() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List { WorkOrderType.PM }), CancellationToken.None); + var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List { WorkOrderType.Reactive }), CancellationToken.None); + await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None); + + (await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { reactive.Id }); + + (await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNull() + { + using var ctx = NewContext(); + (await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs new file mode 100644 index 0000000..fe66268 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs @@ -0,0 +1,110 @@ +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/services")] + public class ServicesRegistryController : Controller + { + private readonly IServicesRegistryService _servicesRegistryService; + + public ServicesRegistryController(IServicesRegistryService servicesRegistryService) + { + _servicesRegistryService = servicesRegistryService; + } + + [HttpGet] + public async Task GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + try + { + var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken); + return Ok(services); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("{id}")] + public async Task GetById(int id, CancellationToken cancellationToken) + { + var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken); + if (service == null) + return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." }); + + return Ok(service); + } + + [HttpPost] + public async Task Create([FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken); + return StatusCode(StatusCodes.Status201Created, created); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPut("{id}")] + public async Task Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken); + return Ok(updated); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id}/deactivate")] + public async Task Deactivate(int id, CancellationToken cancellationToken) + { + try + { + await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken); + return Ok(new { message = "Service deactivated" }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + } + + private IActionResult MapForbidden(ServicesRegistryValidationException ex) + { + return StatusCode(StatusCodes.Status403Forbidden, + new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } +} diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a5256dc..8426000 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -206,6 +206,53 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(t => t.NormalizedName) .IsUnique(); + + // Bounded lengths keep the unique NormalizedName key SQL + // Server-indexable (nvarchar(max) is not). + builder.Entity() + .Property(s => s.Name) + .HasMaxLength(200); + + builder.Entity() + .Property(s => s.NormalizedName) + .HasMaxLength(200); + + builder.Entity() + .HasIndex(s => s.NormalizedName) + .IsUnique(); + + builder.Entity() + .Property(s => s.Trade) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.Trade); + + builder.Entity() + .Property(s => s.IconKey) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.IsActive); + + builder.Entity() + .HasOne(s => s.CompletionDocTemplate) + .WithMany() + .HasForeignKey(s => s.CompletionDocTemplateId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(s => s.CompletionDocTemplateId); + + builder.Entity() + .HasIndex(t => new { t.ServiceId, t.WorkOrderType }) + .IsUnique(); + + builder.Entity() + .HasOne(t => t.Service) + .WithMany(s => s.SupportedWorkOrderTypes) + .HasForeignKey(t => t.ServiceId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -241,6 +288,8 @@ namespace Data.SeaHavenIndustries public DbSet DispatchUpliftRequests { get; set; } public DbSet TaskListTemplates { get; set; } public DbSet TaskListTemplateItems { get; set; } + public DbSet Services { get; set; } + public DbSet ServiceWorkOrderTypes { get; set; } // New DbSets for added entities public DbSet Accounts { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs new file mode 100644 index 0000000..15ed6d7 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs @@ -0,0 +1,4074 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260916185443_SH303_ServicesRegistry")] + partial class SH303_ServicesRegistry + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany("Contacts") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Contacts"); + + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.cs b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.cs new file mode 100644 index 0000000..41fe63d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH303_ServicesRegistry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Services", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Trade = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + IconKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + RequiresCompletionDocument = table.Column(type: "bit", nullable: false), + CompletionDocTemplateId = table.Column(type: "int", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Services", x => x.Id); + table.ForeignKey( + name: "FK_Services_CompletionDocTemplates_CompletionDocTemplateId", + column: x => x.CompletionDocTemplateId, + principalTable: "CompletionDocTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ServiceWorkOrderTypes", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ServiceId = table.Column(type: "int", nullable: false), + WorkOrderType = table.Column(type: "int", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ServiceWorkOrderTypes", x => x.Id); + table.ForeignKey( + name: "FK_ServiceWorkOrderTypes_Services_ServiceId", + column: x => x.ServiceId, + principalTable: "Services", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Services_CompletionDocTemplateId", + table: "Services", + column: "CompletionDocTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_Services_IsActive", + table: "Services", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_Services_NormalizedName", + table: "Services", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Services_Trade", + table: "Services", + column: "Trade"); + + migrationBuilder.CreateIndex( + name: "IX_ServiceWorkOrderTypes_ServiceId_WorkOrderType", + table: "ServiceWorkOrderTypes", + columns: new[] { "ServiceId", "WorkOrderType" }, + unique: true); + + // Additive seed: one active service per active Problem dropdown + // value already stored in the database (never the prototype's demo + // leaves). Idempotent via NormalizedName NOT EXISTS; dropdown rows are + // only read, never altered. Repeated source values collapse onto a + // deterministic MIN(Value) representative per normalized key. + migrationBuilder.Sql( + """ + INSERT INTO [Services] ([Name], [NormalizedName], [Trade], [IconKey], [RequiresCompletionDocument], [CompletionDocTemplateId], [IsActive], [IsDeleted], [createdby]) + SELECT seeded.[Name], seeded.[NormalizedName], seeded.[Trade], + CASE seeded.[Trade] + WHEN N'Plumbing & Water Systems' THEN N'plumbing-water-systems' + WHEN N'Electrical' THEN N'electrical' + WHEN N'HVAC' THEN N'hvac' + WHEN N'Fire & Life Safety' THEN N'fire-life-safety' + ELSE N'general-building-handyman' + END, + 0, NULL, 1, 0, N'sh303-migration' + FROM ( + SELECT agg.[Name], agg.[NormalizedName], + CASE + WHEN agg.[Name] IN (N'Leak', N'Clogged') THEN N'Plumbing & Water Systems' + WHEN agg.[Name] = N'No Power' THEN N'Electrical' + WHEN agg.[Name] IN (N'Not Cooling', N'Not Heating') THEN N'HVAC' + WHEN agg.[Name] = N'Alarm Fault' THEN N'Fire & Life Safety' + ELSE N'General Building & Handyman' + END AS [Trade] + FROM ( + SELECT MIN(src.[Value]) AS [Name], + LOWER(LTRIM(RTRIM(src.[Value]))) AS [NormalizedName] + FROM [DropdownOptions] src + WHERE src.[Category] = N'Problem' + AND src.[IsActive] = 1 + GROUP BY LOWER(LTRIM(RTRIM(src.[Value]))) + ) agg + ) seeded + WHERE NOT EXISTS ( + SELECT 1 FROM [Services] s WHERE s.[NormalizedName] = seeded.[NormalizedName] + ); + + INSERT INTO [ServiceWorkOrderTypes] ([ServiceId], [WorkOrderType], [IsDeleted]) + SELECT s.[Id], types.[WorkOrderType], 0 + FROM [Services] s + CROSS JOIN (VALUES (2), (6), (3)) AS types([WorkOrderType]) + WHERE s.[createdby] = N'sh303-migration' + AND NOT EXISTS ( + SELECT 1 FROM [ServiceWorkOrderTypes] x + WHERE x.[ServiceId] = s.[Id] + AND x.[WorkOrderType] = types.[WorkOrderType] + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServiceWorkOrderTypes"); + + migrationBuilder.DropTable( + name: "Services"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 7bf6f39..04dfa9e 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1762,6 +1762,121 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("Regions"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => { b.Property("Id") @@ -3521,6 +3636,27 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Quotes"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => { b.HasOne("Data.SeaHavenIndustries.Locations", "Location") @@ -3889,6 +4025,11 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Employees"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => { b.Navigation("Items"); diff --git a/Data.SeaHavenIndustries/Models/Service.cs b/Data.SeaHavenIndustries/Models/Service.cs new file mode 100644 index 0000000..adba3f4 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/Service.cs @@ -0,0 +1,29 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + // Global (tenant-scope free) Services registry. Services are never + // hard deleted; deactivation is a soft IsActive state change so historical + // work-order references remain resolvable. + public class Service : FullAuditEntity + { + public string Name { get; set; } = ""; + public string NormalizedName { get; set; } = ""; + public string Trade { get; set; } = ""; + public string IconKey { get; set; } = ""; + public bool RequiresCompletionDocument { get; set; } + public int? CompletionDocTemplateId { get; set; } + public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; } + public bool IsActive { get; set; } = true; + public virtual ICollection SupportedWorkOrderTypes { get; set; } = new List(); + } + + // Supported work-order types (PM, Reactive, Emergency) stored as an + // explicitly queryable relation so work-order features can filter by type. + public class ServiceWorkOrderType : FullAuditEntity + { + public int ServiceId { get; set; } + public virtual Service? Service { get; set; } + public WorkOrderType WorkOrderType { get; set; } + } +} diff --git a/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs b/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs new file mode 100644 index 0000000..b6acd06 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/ServicesRegistryDataService.cs @@ -0,0 +1,130 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class ServicesRegistryDataService : IServicesRegistryDataService + { + private readonly ApplicationDbContext _context; + + public ServicesRegistryDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + var query = _context.Services + .AsNoTracking() + .Where(s => s.IsDeleted != true); + + if (isActive.HasValue) + query = query.Where(s => s.IsActive == isActive.Value); + + if (workOrderType.HasValue) + query = query.Where(s => s.SupportedWorkOrderTypes.Any(t => t.WorkOrderType == workOrderType.Value)); + + var rows = await query + .OrderBy(s => s.Name) + .Select(s => new ServiceRegistryRow + { + Id = s.Id, + Name = s.Name, + Trade = s.Trade, + IconKey = s.IconKey, + RequiresCompletionDocument = s.RequiresCompletionDocument, + IsActive = s.IsActive, + CompletionDocTemplateId = s.CompletionDocTemplateId, + CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null, + SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList() + }) + .ToListAsync(cancellationToken); + return rows; + } + + public async Task GetServiceRowByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.Services.AsNoTracking() + .Where(s => s.Id == id && s.IsDeleted != true) + .Select(s => new ServiceRegistryRow + { + Id = s.Id, + Name = s.Name, + Trade = s.Trade, + IconKey = s.IconKey, + RequiresCompletionDocument = s.RequiresCompletionDocument, + IsActive = s.IsActive, + CompletionDocTemplateId = s.CompletionDocTemplateId, + CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null, + SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetServiceForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.Services + .Include(s => s.SupportedWorkOrderTypes) + .FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken); + } + + public async Task NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken) + { + return await _context.Services + .AnyAsync(s => s.IsDeleted != true + && s.NormalizedName == normalizedName + && (excludeServiceId == null || s.Id != excludeServiceId), + cancellationToken); + } + + public async Task GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken) + { + return await _context.CompletionDocTemplates + .Where(t => t.Id == templateId && t.IsActive && t.IsDeleted != true) + .Select(t => new LinkedTemplateSummary { Id = t.Id, Name = t.Name }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task AddServiceAsync(Service service, CancellationToken cancellationToken) + { + await _context.Services.AddAsync(service, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return service; + } + + public async Task SaveServiceAsync(Service service, IReadOnlyCollection supportedWorkOrderTypes, CancellationToken cancellationToken) + { + var desired = supportedWorkOrderTypes.Distinct().ToList(); + var current = service.SupportedWorkOrderTypes ?? new List(); + + var stale = current.Where(t => !desired.Contains(t.WorkOrderType)).ToList(); + _context.ServiceWorkOrderTypes.RemoveRange(stale); + + var existingTypes = current.Select(t => t.WorkOrderType).ToHashSet(); + foreach (var type in desired.Where(t => !existingTypes.Contains(t))) + { + _context.ServiceWorkOrderTypes.Add(new ServiceWorkOrderType + { + ServiceId = service.Id, + WorkOrderType = type + }); + } + + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeactivateServiceAsync(int id, CancellationToken cancellationToken) + { + var service = await _context.Services + .FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken); + if (service == null) + return false; + + service.IsActive = false; + await _context.SaveChangesAsync(cancellationToken); + return true; + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs b/SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs new file mode 100644 index 0000000..a09e465 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IServicesRegistryDataService.cs @@ -0,0 +1,39 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + // Read-side projection for the global Services registry. The linked + // completion template is projected to id/name only — full template + // entities are never materialized. + public class ServiceRegistryRow + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Trade { get; set; } = ""; + public string IconKey { get; set; } = ""; + public bool RequiresCompletionDocument { get; set; } + public bool IsActive { get; set; } + public int? CompletionDocTemplateId { get; set; } + public string? CompletionDocTemplateName { get; set; } + public List SupportedWorkOrderTypes { get; set; } = new(); + } + + public class LinkedTemplateSummary + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + public interface IServicesRegistryDataService + { + Task> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken); + Task GetServiceRowByIdAsync(int id, CancellationToken cancellationToken); + Task GetServiceForUpdateAsync(int id, CancellationToken cancellationToken); + Task NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken); + Task GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken); + Task AddServiceAsync(Service service, CancellationToken cancellationToken); + Task SaveServiceAsync(Service service, IReadOnlyCollection supportedWorkOrderTypes, CancellationToken cancellationToken); + Task DeactivateServiceAsync(int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs b/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs new file mode 100644 index 0000000..70fe43b --- /dev/null +++ b/SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs @@ -0,0 +1,38 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class ServiceDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Trade { get; set; } = ""; + public string IconKey { get; set; } = ""; + public bool RequiresCompletionDocument { get; set; } + public LinkedTemplateSummaryDto? CompletionDocTemplate { get; set; } + public bool IsActive { get; set; } + public List SupportedWorkOrderTypes { get; set; } = new(); + } + + public class LinkedTemplateSummaryDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + public class ServiceInput + { + public string? Name { get; set; } + public string? Trade { get; set; } + public string? IconKey { get; set; } + public bool RequiresCompletionDocument { get; set; } + public int? CompletionDocTemplateId { get; set; } + public List? SupportedWorkOrderTypes { get; set; } + } + + public class ServicesRegistryValidationErrorDto + { + public string Code { get; set; } = ""; + public string Message { get; set; } = ""; + } +} diff --git a/SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs b/SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs new file mode 100644 index 0000000..bbec299 --- /dev/null +++ b/SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs @@ -0,0 +1,12 @@ +namespace SeaHaven.Services.Exceptions +{ + public class ServicesRegistryValidationException : Exception + { + public ServicesRegistryValidationException(string code, string message) : base(message) + { + Code = code; + } + + public string Code { get; } + } +} diff --git a/SeaHaven.Services/Implementation/ServicesRegistryService.cs b/SeaHaven.Services/Implementation/ServicesRegistryService.cs new file mode 100644 index 0000000..79e2dd9 --- /dev/null +++ b/SeaHaven.Services/Implementation/ServicesRegistryService.cs @@ -0,0 +1,226 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class ServicesRegistryService : IServicesRegistryService + { + // Product contract: the registry only accepts the three work-order types + // the product currently supports. Any other WorkOrderType is rejected. + public static readonly IReadOnlySet AllowedWorkOrderTypes = + new HashSet { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }; + + private static readonly WorkOrderType[] DefaultSupportedWorkOrderTypes = + { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }; + + public const string SchedulerRole = "Scheduler"; + public const string AdminRole = "Admin"; + + private const int NameMaxLength = 200; + private const int IconKeyMaxLength = 64; + + private readonly IServicesRegistryDataService _dataService; + + public ServicesRegistryService(IServicesRegistryDataService dataService) + { + _dataService = dataService; + } + + public async Task> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + ValidateWorkOrderTypeFilter(workOrderType); + var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken); + return rows.Select(MapToDto).ToList(); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken); + return row == null ? null : MapToDto(row); + } + + public async Task CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken) + { + EnsureCanMutate(user); + + var name = RequireValidName(input.Name); + var trade = RequireCanonicalTrade(input.Trade); + var iconKey = RequireValidIconKey(input.IconKey); + var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes); + var template = await ResolveTemplateLinkAsync(input, cancellationToken); + + if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), null, cancellationToken)) + throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."); + + var service = new Service + { + Name = name, + NormalizedName = TradeCatalog.Normalize(name), + Trade = trade, + IconKey = iconKey, + RequiresCompletionDocument = input.RequiresCompletionDocument, + CompletionDocTemplateId = template?.Id, + IsActive = true, + SupportedWorkOrderTypes = supportedTypes + .Select(t => new ServiceWorkOrderType { WorkOrderType = t }) + .ToList() + }; + + await _dataService.AddServiceAsync(service, cancellationToken); + + var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken); + return MapToDto(row!); + } + + public async Task UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken) + { + EnsureCanMutate(user); + + var service = await _dataService.GetServiceForUpdateAsync(id, cancellationToken); + if (service == null || service.IsDeleted == true) + throw new ServicesRegistryValidationException("NotFound", "Service not found."); + + var name = RequireValidName(input.Name); + var trade = RequireCanonicalTrade(input.Trade); + var iconKey = RequireValidIconKey(input.IconKey); + var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes); + var template = await ResolveTemplateLinkAsync(input, cancellationToken); + + if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), id, cancellationToken)) + throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."); + + service.Name = name; + service.NormalizedName = TradeCatalog.Normalize(name); + service.Trade = trade; + service.IconKey = iconKey; + service.RequiresCompletionDocument = input.RequiresCompletionDocument; + service.CompletionDocTemplateId = template?.Id; + + await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken); + + var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken); + return MapToDto(row!); + } + + public async Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken) + { + if (user is null || !user.IsInRole(AdminRole)) + throw new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."); + + var deactivated = await _dataService.DeactivateServiceAsync(id, cancellationToken); + if (!deactivated) + throw new ServicesRegistryValidationException("NotFound", "Service not found."); + } + + private static void EnsureCanMutate(ClaimsPrincipal user) + { + if (user is null || !(user.IsInRole(SchedulerRole) || user.IsInRole(AdminRole))) + throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."); + } + + private static string RequireValidName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ServicesRegistryValidationException("NameRequired", "Service name is required."); + + var trimmed = name.Trim(); + if (trimmed.Length > NameMaxLength) + throw new ServicesRegistryValidationException("NameInvalid", $"Service name cannot exceed {NameMaxLength} characters."); + + return trimmed; + } + + private static string RequireCanonicalTrade(string? trade) + { + if (string.IsNullOrWhiteSpace(trade)) + throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values."); + + var normalized = TradeCatalog.Normalize(trade); + var canonical = TradeCatalog.CanonicalTrades + .FirstOrDefault(t => TradeCatalog.Normalize(t) == normalized); + + if (canonical == null) + throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values."); + + return canonical; + } + + private static string RequireValidIconKey(string? iconKey) + { + if (string.IsNullOrWhiteSpace(iconKey)) + throw new ServicesRegistryValidationException("IconKeyInvalid", "Icon key is required."); + + var trimmed = iconKey.Trim(); + if (trimmed.Length > IconKeyMaxLength) + throw new ServicesRegistryValidationException("IconKeyInvalid", $"Icon key cannot exceed {IconKeyMaxLength} characters."); + + return trimmed; + } + + private static List RequireValidSupportedTypes(IEnumerable? supportedTypes) + { + // The registry form does not expose type categories yet. Until it + // does, an omitted or empty category list keeps a service available + // to the three supported work-order types. + var types = supportedTypes?.Distinct().ToList(); + if (types == null || types.Count == 0) + return DefaultSupportedWorkOrderTypes.ToList(); + + if (types.Any(t => !AllowedWorkOrderTypes.Contains(t))) + throw new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", + "Supported work order types may only include PM, Reactive, or Emergency."); + + return types; + } + + private static void ValidateWorkOrderTypeFilter(WorkOrderType? workOrderType) + { + if (workOrderType.HasValue && !AllowedWorkOrderTypes.Contains(workOrderType.Value)) + throw new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", + "Supported work order types may only include PM, Reactive, or Emergency."); + } + + private async Task ResolveTemplateLinkAsync(ServiceInput input, CancellationToken cancellationToken) + { + // Toggle off clears/ignores any template link. + if (!input.RequiresCompletionDocument || input.CompletionDocTemplateId is not int templateId) + return null; + + var template = await _dataService.GetActiveTemplateSummaryAsync(templateId, cancellationToken); + if (template == null) + throw new ServicesRegistryValidationException( + "TemplateInvalid", + "Completion document template must reference an active, non-deleted template."); + + return template; + } + + private static ServiceDto MapToDto(ServiceRegistryRow row) + { + return new ServiceDto + { + Id = row.Id, + Name = row.Name, + Trade = row.Trade, + IconKey = row.IconKey, + RequiresCompletionDocument = row.RequiresCompletionDocument, + CompletionDocTemplate = row.CompletionDocTemplateId == null + ? null + : new LinkedTemplateSummaryDto + { + Id = row.CompletionDocTemplateId.Value, + Name = row.CompletionDocTemplateName ?? "" + }, + IsActive = row.IsActive, + SupportedWorkOrderTypes = row.SupportedWorkOrderTypes.OrderBy(t => (int)t).ToList() + }; + } + } +} diff --git a/SeaHaven.Services/Interfaces/IServicesRegistryService.cs b/SeaHaven.Services/Interfaces/IServicesRegistryService.cs new file mode 100644 index 0000000..771238b --- /dev/null +++ b/SeaHaven.Services/Interfaces/IServicesRegistryService.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + // Global Services registry. Tenant scope is global (no AccountId), + // matching DropdownOptions. Reads are authenticated; create/update require + // the Scheduler or Admin role and deactivation is Admin-only, enforced at + // this service entry — never in the controller alone. + public interface IServicesRegistryService + { + Task> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken); + Task UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken); + Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs b/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs new file mode 100644 index 0000000..6dbb735 --- /dev/null +++ b/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs @@ -0,0 +1,316 @@ +using System.Reflection; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Migrations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +// Shape evidence for the global Services registry migration (table, +// indexes, FK, idempotent additive seed) plus a SQL Server execution test that +// is silently skipped when no provider is reachable, mirroring existing +// provider-gated migration tests. +public class SH303ServicesRegistryMigrationTests +{ + private const string LocalDbMaster = + @"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3"; + + [Fact] + public void Up_CreatesRegistryTablesUniqueIndexesFkAndIdempotentProblemSeed() + { + var operations = BuildOperations("Up"); + + var createTables = operations.OfType().ToList(); + Assert.Equal( + new HashSet { "Services", "ServiceWorkOrderTypes" }, + createTables.Select(t => t.Name).ToHashSet()); + + var services = createTables.Single(t => t.Name == "Services"); + Assert.Contains(services.Columns, c => c.Name == "NormalizedName" && (string)c.ColumnType! == "nvarchar(200)"); + Assert.Contains(services.Columns, c => c.Name == "CompletionDocTemplateId" && c.IsNullable); + Assert.Contains(services.Columns, c => c.Name == "RequiresCompletionDocument" && !c.IsNullable); + Assert.Contains(services.Columns, c => c.Name == "IsActive" && !c.IsNullable); + + var foreignKeys = services.ForeignKeys.Concat( + createTables.Single(t => t.Name == "ServiceWorkOrderTypes").ForeignKeys) + .ToList(); + Assert.Contains(foreignKeys, fk => + fk.Table == "Services" + && fk.PrincipalTable == "CompletionDocTemplates" + && fk.OnDelete == ReferentialAction.Restrict); + Assert.Contains(foreignKeys, fk => + fk.Table == "ServiceWorkOrderTypes" && fk.PrincipalTable == "Services"); + + var indexes = operations.OfType().ToList(); + Assert.Contains(indexes, i => + i.Table == "Services" && i.Columns.SequenceEqual(new[] { "NormalizedName" }) && i.IsUnique); + Assert.Contains(indexes, i => + i.Table == "ServiceWorkOrderTypes" + && i.Columns.SequenceEqual(new[] { "ServiceId", "WorkOrderType" }) + && i.IsUnique); + + // The seed is raw additive SQL: read-only over DropdownOptions, idempotent + // via NormalizedName NOT EXISTS, and never mutates dropdown rows. + var seedSql = string.Join("\n", operations.OfType().Select(o => o.Sql)); + Assert.Contains("[Category] = N'Problem'", seedSql); + Assert.Contains("[IsActive] = 1", seedSql); + Assert.Contains("NOT EXISTS", seedSql); + Assert.Contains("INSERT INTO [Services]", seedSql); + Assert.Contains("INSERT INTO [ServiceWorkOrderTypes]", seedSql); + Assert.DoesNotContain("UPDATE [DropdownOptions]", seedSql); + Assert.DoesNotContain("DELETE FROM [DropdownOptions]", seedSql); + Assert.DoesNotContain("DELETE [DropdownOptions]", seedSql); + } + + [Fact] + public void Down_DropsRegistryTables() + { + var operations = BuildOperations("Down"); + + var drops = operations.OfType().Select(d => d.Name).ToList(); + Assert.Equal( + new HashSet { "Services", "ServiceWorkOrderTypes" }, + drops.ToHashSet()); + Assert.Empty(operations.OfType()); + } + + [Fact] + public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable() + { + var masterConnectionString = await ResolveMasterConnectionStringAsync(); + if (masterConnectionString == null) + return; + + var dbName = $"SH303Registry_{Guid.NewGuid():N}"; + var connectionString = WithDatabase(masterConnectionString, dbName); + + try + { + await CreateDatabaseAsync(masterConnectionString, dbName); + + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + await using (var seedSource = connection.CreateCommand()) + { + seedSource.CommandText = + """ + CREATE TABLE DropdownOptions ( + Id int NOT NULL IDENTITY PRIMARY KEY, + Category nvarchar(max) NOT NULL, + Value nvarchar(max) NOT NULL, + ParentValue nvarchar(max) NULL, + SortOrder int NOT NULL, + IsActive bit NOT NULL + ); + CREATE TABLE CompletionDocTemplates ( + Id int NOT NULL IDENTITY PRIMARY KEY, + Name nvarchar(200) NOT NULL, + ServiceKey nvarchar(max) NOT NULL, + TemplateUrl nvarchar(max) NOT NULL, + IsActive bit NOT NULL, + IsDeleted bit NULL, + createdby nvarchar(max) NULL, + DeleterUserId nvarchar(max) NULL, + DeletionTime datetime2 NULL, + CreatedDate datetime2 NULL, + LastModificationTime datetime2 NULL, + LastModifierUserId int NULL + ); + INSERT INTO DropdownOptions (Category, Value, SortOrder, IsActive) VALUES + (N'Problem', N'Leak', 0, 1), + (N'Problem', N'leak', 1, 1), + (N'Problem', N'No Power', 2, 1), + (N'Problem', N'Not Cooling', 3, 1), + (N'Problem', N'Not Heating', 4, 1), + (N'Problem', N'Alarm Fault', 5, 1), + (N'Problem', N'Broken/Damaged', 6, 1), + (N'Problem', N'Retired Ghost', 7, 0), + (N'Trade', N'Electrical', 0, 1); + """; + await seedSource.ExecuteNonQueryAsync(); + } + + await using var context = new ApplicationDbContext( + new DbContextOptionsBuilder().UseSqlServer(connection).Options); + await ApplyMigrationAsync(context, new SH303_ServicesRegistry()); + + await using (var servicesCmd = connection.CreateCommand()) + { + servicesCmd.CommandText = + """ + SELECT [Name], [Trade], [IconKey] + FROM [Services] + ORDER BY [Name]; + """; + await using var reader = await servicesCmd.ExecuteReaderAsync(); + var rows = new List(); + while (await reader.ReadAsync()) + rows.Add($"{reader.GetString(0)}|{reader.GetString(1)}|{reader.GetString(2)}"); + + // Repeated case variants collapse onto one deterministic row and the + // inactive value plus the non-Problem category never seed. + var expected = new[] + { + "Alarm Fault|Fire & Life Safety|fire-life-safety", + "Broken/Damaged|General Building & Handyman|general-building-handyman", + "Leak|Plumbing & Water Systems|plumbing-water-systems", + "No Power|Electrical|electrical", + "Not Cooling|HVAC|hvac", + "Not Heating|HVAC|hvac" + }; + Assert.Equal(expected, rows.OrderBy(r => r).ToArray()); + } + + await using (var typesCmd = connection.CreateCommand()) + { + typesCmd.CommandText = + """ + SELECT COUNT(*) + FROM [ServiceWorkOrderTypes] t + JOIN [Services] s ON s.[Id] = t.[ServiceId] + WHERE t.[WorkOrderType] IN (2, 3, 6); + """; + Assert.Equal(18, Convert.ToInt32(await typesCmd.ExecuteScalarAsync())); // 6 services x 3 supported types + } + + await using (var untouchedCmd = connection.CreateCommand()) + { + untouchedCmd.CommandText = "SELECT COUNT(*) FROM [DropdownOptions];"; + Assert.Equal(9, Convert.ToInt32(await untouchedCmd.ExecuteScalarAsync())); + } + + // Re-running only the seed statements must be a no-op (idempotence). + var before = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"); + await ApplySeedStatementsAsync(context); + var after = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"); + Assert.Equal(before, after); + + var migration = new SH303_ServicesRegistry(); + await ApplyMigrationAsync(context, migration, "Down"); + Assert.Equal(0, await CountAsync(connection, "SELECT COUNT(*) FROM sys.tables WHERE name IN ('Services', 'ServiceWorkOrderTypes');")); + + await ApplyMigrationAsync(context, migration, "Up"); + Assert.Equal(6, await CountAsync(connection, "SELECT COUNT(*) FROM [Services];")); + Assert.Equal(18, await CountAsync(connection, "SELECT COUNT(*) FROM [ServiceWorkOrderTypes];")); + } + finally + { + await DropDatabaseAsync(masterConnectionString, dbName); + } + } + + private static List BuildOperations(string direction) + { + var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); + var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Migration.{direction} not found."); + operation.Invoke(new SH303_ServicesRegistry(), [builder]); + return builder.Operations; + } + + private static async Task ApplyMigrationAsync(ApplicationDbContext context, Migration migration, string direction = "Up") + { + var builder = new MigrationBuilder(context.Database.ProviderName!); + var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Migration.{direction} not found."); + operation.Invoke(migration, [builder]); + + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(builder.Operations, context.Model); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task ApplySeedStatementsAsync(ApplicationDbContext context) + { + var sqlOperations = BuildOperations("Up").OfType().ToList(); + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(sqlOperations, context.Model); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task CountAsync(SqlConnection connection, string commandText) + { + await using var command = connection.CreateCommand(); + command.CommandText = commandText; + return Convert.ToInt32(await command.ExecuteScalarAsync()); + } + + private static async Task ResolveMasterConnectionStringAsync() + { + var configured = Environment.GetEnvironmentVariable("SH303_SQLSERVER_MASTER"); + var candidate = !string.IsNullOrWhiteSpace(configured) + ? WithDatabase(configured, "master") + : OperatingSystem.IsWindows() ? LocalDbMaster : null; + + if (candidate == null) + return null; + + try + { + await using var connection = new SqlConnection(candidate); + await connection.OpenAsync(); + return candidate; + } + catch (SqlException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private static async Task CreateDatabaseAsync(string masterConnectionString, string dbName) + { + await using var connection = new SqlConnection(masterConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"CREATE DATABASE {QuoteSqlServerIdentifier(dbName)};"; + await command.ExecuteNonQueryAsync(); + } + + private static async Task DropDatabaseAsync(string masterConnectionString, string dbName) + { + try + { + var quoted = QuoteSqlServerIdentifier(dbName); + await using var connection = new SqlConnection(masterConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = + $""" + IF DB_ID(@dbName) IS NOT NULL + BEGIN + ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE {quoted}; + END + """; + command.Parameters.AddWithValue("@dbName", dbName); + await command.ExecuteNonQueryAsync(); + } + catch + { + // Best-effort cleanup for ephemeral SQL Server databases. + } + } + + private static string QuoteSqlServerIdentifier(string name) => + "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; + + private static string WithDatabase(string connectionString, string database) + { + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = database + }; + return builder.ConnectionString; + } +} From dec8b15278b73b7379568fd44c605139b3626831 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:31:21 -0300 Subject: [PATCH 2/3] 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 b3e9a2eec3f8aa350162200d49f7217568930214 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 22:30:32 -0300 Subject: [PATCH 3/3] 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.");