From 8af9ad076fdf5a063b1a547a78bd1c7076f2085c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 01:24:43 -0300 Subject: [PATCH 1/8] fix: map concurrent uplift inserts to conflict --- .../UpliftWorkflowTests.cs | 35 +++++- .../VendorPortalControllerTests.cs | 29 +++++ .../WorkOrderUpliftControllerTests.cs | 23 ++++ .../WorkOrderUpliftServiceConflictTests.cs | 113 ++++++++++++++++++ .../Controllers/VendorPortalController.cs | 5 + .../Controllers/WorkOrderDetailController.cs | 4 + .../UpliftDispatchConflictException.cs | 9 ++ .../Implementation/UpliftDataService.cs | 50 +++++++- .../Exceptions/UpliftConflictException.cs | 9 ++ .../Implementation/VendorPortalService.cs | 10 +- .../Implementation/WorkOrderUpliftService.cs | 27 +++-- .../UpliftDataServiceTransactionTests.cs | 101 ++++++++++++++++ 12 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs create mode 100644 SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs create mode 100644 SeaHaven.Services/Exceptions/UpliftConflictException.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 9b3f220..b57d36f 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests private static VendorPortalService NewPortalService( ApplicationDbContext context, IEmailSender emailSender, - IVendorDocumentStoragePort? storage = null) + IVendorDocumentStoragePort? storage = null, + IUpliftDataService? upliftData = null) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); @@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests return new VendorPortalService( tokenService, new DispatchDataService(context), - new UpliftDataService(context), + upliftData ?? new UpliftDataService(context), commentData.Object, Mock.Of(), emailSender, @@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests result.RequiredTier.Should().Be(2); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + + var upliftData = new Mock(); + upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException()); + + var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var act = () => service.RequestUpliftAsync( + session!, + dispatch.Id, + 1500m, + "reason", + null, + 1, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + // --- Evidence scan + cross-vendor/dispatch scoping --- [Fact] diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index dd9e40e..bc097b5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using Xunit; @@ -309,6 +310,34 @@ public class VendorPortalControllerTests result.Should().BeOfType(); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync( + Session, + 10, + 1500m, + "Need more parts", + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody + { + RequestedNTE = 1500m, + Reason = "Need more parts", + }); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task RequestUplift_Success_ReturnsResult() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index b3cdbfc..3784558 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; @@ -78,6 +79,28 @@ public sealed class WorkOrderUpliftControllerTests Assert.Equal(12, created.Id); } + [Fact] + public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + + var controller = NewController(service); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" }, + CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task CancelUplift_NotFound_Returns404() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs new file mode 100644 index 0000000..129e360 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -0,0 +1,113 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Moq; +using SeaHaven.DataServices.Exceptions; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class WorkOrderUpliftServiceConflictTests +{ + [Fact] + public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict() + { + var upliftData = new Mock(); + var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var savesArrived = 0; + var winner = 0; + + upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (_, work, cancellationToken) => work(cancellationToken)); + upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0m); + upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) + .Returns(async () => + { + if (Interlocked.Increment(ref savesArrived) == 2) + bothAtInsert.TrySetResult(); + + await bothAtInsert.Task.WaitAsync(TimeSpan.FromSeconds(5)); + if (Interlocked.Exchange(ref winner, 1) != 0) + throw new UpliftDispatchConflictException(); + }); + + var detailData = new Mock(); + detailData.Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder + { + Id = workOrderId, + PrimaryDispatchId = 10, + WorkOrderType = WorkOrderType.PM, + LifecycleStatus = LifecycleStatus.Scheduled, + }); + + var dispatchData = new Mock(); + dispatchData.Setup(x => x.GetByIdAsync(10)) + .ReturnsAsync(new Dispatch { Id = 10, Status = "Scheduled", NTEAmount = 1000m }); + dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var accountResolver = new Mock(); + accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny())).Returns((int?)null); + var userData = new Mock(); + userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var service = new WorkOrderUpliftService( + upliftData.Object, + dispatchData.Object, + detailData.Object, + accountResolver.Object, + userData.Object, + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + })); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") }, + "test")); + + static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture( + Func> create) + { + try + { + return (await create(), null); + } + catch (Exception exception) + { + return (null, exception); + } + } + + var outcomes = await Task.WhenAll( + Capture(() => service.CreateAsync(1, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)), + Capture(() => service.CreateAsync(2, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None))); + + Assert.Single(outcomes, outcome => outcome.Result != null); + Assert.IsType(Assert.Single(outcomes, outcome => outcome.Error != null).Error); + upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny()), Times.Exactly(2)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..636f963 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -217,6 +218,10 @@ namespace Api.SeaHavenIndustries.Controllers var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = result }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index d975b32..742eaaf 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -164,6 +164,10 @@ namespace Api.SeaHavenIndustries.Controllers return NotFound(new Response { Status = "Error", Message = "Work order not found." }); return Ok(new DataResponse { Status = "Success", Data = created }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException ex) { return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); diff --git a/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs b/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs new file mode 100644 index 0000000..0063ebd --- /dev/null +++ b/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.DataServices.Exceptions; + +public sealed class UpliftDispatchConflictException : Exception +{ + public UpliftDispatchConflictException() + : base("An active uplift request already exists for this dispatch.") + { + } +} diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 1d72cf5..a41d487 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Exceptions; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -525,7 +526,54 @@ namespace SeaHaven.DataServices.Implementation public async Task SaveChangesAsync(CancellationToken cancellationToken) { - await _context.SaveChangesAsync(cancellationToken); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (IsActiveDispatchUniqueIndexViolation(exception)) + { + throw new UpliftDispatchConflictException(); + } + } + + private bool IsActiveDispatchUniqueIndexViolation(DbUpdateException exception) + { + var provider = _context.Database.ProviderName; + var databaseException = exception.GetBaseException(); + var exceptionType = databaseException.GetType(); + + if (provider?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) == true + && exceptionType.FullName == "Microsoft.Data.SqlClient.SqlException") + { + var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?; + return number is 2601 or 2627 + && databaseException.Message.Contains( + "IX_DispatchUpliftRequests_DispatchId", + StringComparison.OrdinalIgnoreCase); + } + + if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true + && exceptionType.FullName == "Microsoft.Data.Sqlite.SqliteException") + { + var extendedCode = exceptionType.GetProperty("SqliteExtendedErrorCode")?.GetValue(databaseException) as int?; + const string uniqueConstraintPrefix = "UNIQUE constraint failed: "; + var message = databaseException.Message; + var prefixIndex = message.IndexOf(uniqueConstraintPrefix, StringComparison.OrdinalIgnoreCase); + if (extendedCode != 2067 || prefixIndex < 0) + return false; + + var columnsStart = prefixIndex + uniqueConstraintPrefix.Length; + var columnsEnd = message.IndexOf('\'', columnsStart); + if (columnsEnd < 0) + columnsEnd = message.Length; + var columns = message[columnsStart..columnsEnd].Trim(); + return string.Equals( + columns, + "DispatchUpliftRequests.DispatchId", + StringComparison.OrdinalIgnoreCase); + } + + return false; } public async Task ExecuteWorkOrderMutationAsync( diff --git a/SeaHaven.Services/Exceptions/UpliftConflictException.cs b/SeaHaven.Services/Exceptions/UpliftConflictException.cs new file mode 100644 index 0000000..9e21b4e --- /dev/null +++ b/SeaHaven.Services/Exceptions/UpliftConflictException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.Services.Exceptions; + +public sealed class UpliftConflictException : Exception +{ + public UpliftConflictException() + : base("An active uplift request already exists for the associated dispatch. Refresh and retry.") + { + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 5fc5140..c595809 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -651,7 +652,14 @@ namespace SeaHaven.Services.Implementation CreatedAt = now }, cancellationToken); - await _upliftData.SaveChangesAsync(cancellationToken); + try + { + await _upliftData.SaveChangesAsync(cancellationToken); + } + catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException) + { + throw new UpliftConflictException(); + } // Notification is best-effort and persisted separately from workflow state: a failure // never destroys the actionable request (the lifecycle sweep retries by sentinel). diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 149aeaa..cb05911 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -69,17 +69,24 @@ namespace SeaHaven.Services.Implementation var notes = request.Notes?.Trim() ?? ""; var accountFilter = _accountResolver.ResolveAccountFilter(user); - return await _upliftData.ExecuteWorkOrderMutationAsync( - workOrderId, - ct => CreateLockedAsync( + try + { + return await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, - request.Amount, - notes, - userId, - requesterName, - accountFilter, - ct), - cancellationToken); + ct => CreateLockedAsync( + workOrderId, + request.Amount, + notes, + userId, + requesterName, + accountFilter, + ct), + cancellationToken); + } + catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException) + { + throw new UpliftConflictException(); + } } private async Task CreateLockedAsync( diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs index f585766..1742f32 100644 --- a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -9,6 +9,107 @@ namespace SeaHavenIndustries.Tests; public sealed class UpliftDataServiceTransactionTests { + [Fact] + public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestedNTE = 1500m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }, CancellationToken.None); + await data.SaveChangesAsync(CancellationToken.None); + + // The database index is the final guard when competing requests pass + // their dispatch-scoped active-request checks. + var exception = await Assert.ThrowsAsync( + () => data.ExecuteWorkOrderMutationAsync( + 2, + async cancellationToken => + { + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestedNTE = 1700m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }, cancellationToken); + await data.SaveChangesAsync(cancellationToken); + return true; + }, + CancellationToken.None)); + Assert.Equal("An active uplift request already exists for this dispatch.", exception.Message); + Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync()); + } + + [Fact] + public async Task SaveChangesAsync_RequestKeyConflict_IsNotMappedToActiveDispatchConflict() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestKey = "same-key", + RequestedNTE = 1500m, + Status = "Approved", + RequiredTier = 1, + NotificationStatus = "Sent", + }, CancellationToken.None); + await data.SaveChangesAsync(CancellationToken.None); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestKey = "same-key", + RequestedNTE = 1700m, + Status = "Approved", + RequiredTier = 1, + NotificationStatus = "Sent", + }, CancellationToken.None); + + await Assert.ThrowsAsync( + () => data.SaveChangesAsync(CancellationToken.None)); + } + [Fact] public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails() { From 0ca9b767edafe5eb6d92452e5a14d0de4dc89fa7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 01:39:16 -0300 Subject: [PATCH 2/8] fix: distinguish uplift request key conflicts --- .../Implementation/UpliftDataService.cs | 10 +++++++--- SeaHaven.DataServices/Properties/AssemblyInfo.cs | 1 + .../UpliftDataServiceTransactionTests.cs | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index a41d487..64df777 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -547,9 +547,7 @@ namespace SeaHaven.DataServices.Implementation { var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?; return number is 2601 or 2627 - && databaseException.Message.Contains( - "IX_DispatchUpliftRequests_DispatchId", - StringComparison.OrdinalIgnoreCase); + && IsActiveDispatchIndexViolationMessage(databaseException.Message); } if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true @@ -576,6 +574,12 @@ namespace SeaHaven.DataServices.Implementation return false; } + internal static bool IsActiveDispatchIndexViolationMessage(string message) + { + const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'"; + return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase); + } + public async Task ExecuteWorkOrderMutationAsync( int workOrderId, Func> work, diff --git a/SeaHaven.DataServices/Properties/AssemblyInfo.cs b/SeaHaven.DataServices/Properties/AssemblyInfo.cs index 75fc406..15ea248 100644 --- a/SeaHaven.DataServices/Properties/AssemblyInfo.cs +++ b/SeaHaven.DataServices/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")] +[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")] diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs index 1742f32..2825fcd 100644 --- a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -9,6 +9,16 @@ namespace SeaHavenIndustries.Tests; public sealed class UpliftDataServiceTransactionTests { + [Theory] + [InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId'.", true)] + [InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId_RequestKey'.", false)] + public void IsActiveDispatchIndexViolationMessage_MatchesOnlyTheActiveDispatchIndex( + string message, + bool expected) + { + Assert.Equal(expected, UpliftDataService.IsActiveDispatchIndexViolationMessage(message)); + } + [Fact] public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict() { From 3b626cca58e92aec5701a2f69803af5e0768c428 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 03:35:23 -0300 Subject: [PATCH 3/8] fix: make team member creation atomic --- .../TeamMemberServiceTests.cs | 54 +++++- .../Implementation/UserDataService.cs | 19 ++ .../Interfaces/IUserDataService.cs | 1 + .../Implementation/TeamMemberService.cs | 68 ++++---- .../TeamMemberCreateTransactionTests.cs | 162 ++++++++++++++++++ 5 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index 5f5204b..cd2ced8 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -319,7 +319,7 @@ public sealed class TeamMemberServiceTests [Fact] public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500() { - var service = NewService(out var userManager, out var roleManager, out _, out _, out _, out _); + var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _); userManager .Setup(manager => manager.FindByEmailAsync(It.IsAny())) .ReturnsAsync((ApplicationUser?)null); @@ -332,10 +332,57 @@ public sealed class TeamMemberServiceTests result.Success.Should().BeFalse(); result.Error.Should().Be("Email is already in use."); + userData.Verify( + data => data.ExecuteTransactionalAsync(It.IsAny>(), It.IsAny()), + Times.Once); userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never); userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation() + { + var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _); + ApplicationUser? created = null; + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .Callback(user => + { + user.Id = "mid-fail-user"; + created = user; + }) + .ReturnsAsync(IdentityResult.Success); + userManager + .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "Dispatcher")) + .ReturnsAsync(IdentityResult.Success); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + areas + .Setup(data => data.ReplaceAsync( + "mid-fail-user", + It.Is>(value => value.SequenceEqual(new[] { "East", "West" })), + It.IsAny())) + .Returns(Task.CompletedTask); + overrides + .Setup(data => data.SetOverridesAsync( + "mid-fail-user", + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("injected mid-sequence failure")); + + var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None)); + + failure.Should().BeOfType() + .Which.Message.Should().Be("injected mid-sequence failure"); + created.Should().NotBeNull(); + areas.Verify( + data => data.ReplaceAsync("mid-fail-user", It.IsAny>(), It.IsAny()), + Times.Once); + userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); + } + private static TeamMemberService NewService( out Mock> userManager, out Mock> roleManager, @@ -350,6 +397,11 @@ public sealed class TeamMemberServiceTests overrides = new Mock(); userData = new Mock(); permissions = new Mock(); + userData + .Setup(data => data.ExecuteTransactionalAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>((callback, token) => callback(token)); return new TeamMemberService( userManager.Object, roleManager.Object, diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 374d34e..32ac851 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -166,6 +166,25 @@ namespace SeaHaven.DataServices.Implementation await transaction.CommitAsync(cancellationToken); } + public async Task ExecuteTransactionalAsync( + Func callback, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(callback); + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + await callback(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(CancellationToken.None); + throw; + } + } + public async Task GetEmailByIdAsync( string userId, CancellationToken cancellationToken) { diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 0a60d5c..3c74dcf 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken); Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); + Task ExecuteTransactionalAsync(Func callback, CancellationToken cancellationToken); Task GetEmailByIdAsync(string userId, CancellationToken cancellationToken); Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index ba2cd2b..faf64f3 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -52,10 +52,6 @@ public sealed class TeamMemberService : ITeamMemberService if (await _userManager.FindByEmailAsync(email!) is not null) return Failure("Email is already in use."); - var roleError = await EnsureRoleAsync(role!); - if (roleError is not null) - return Failure(roleError); - var now = DateTime.UtcNow; var user = new ApplicationUser { @@ -72,39 +68,44 @@ public sealed class TeamMemberService : ITeamMemberService PendingRegistrationCreatedDate = now }; - IdentityResult createResult; try { - createResult = await _userManager.CreateAsync(user); - } - catch (DbUpdateException) - { - // A concurrent create won the race on the unique user-name index - // between the FindByEmailAsync check above and this insert. Surface - // the same conflict message instead of letting the database - // exception bubble up as a 500. - return Failure("Email is already in use."); - } + await _userDataService.ExecuteTransactionalAsync( + async transactionCancellationToken => + { + var roleError = await EnsureRoleAsync(role!); + if (roleError is not null) + throw new TeamMemberCreateException(roleError); - if (!createResult.Succeeded) - return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member."); + IdentityResult createResult; + try + { + createResult = await _userManager.CreateAsync(user); + } + catch (DbUpdateException) + { + // A concurrent create won the race on the unique user-name index + // between the FindByEmailAsync check above and this insert. Surface + // the same conflict message instead of letting the database + // exception bubble up as a 500. + throw new TeamMemberCreateException("Email is already in use."); + } - var addRoleResult = await _userManager.AddToRoleAsync(user, role!); - if (!addRoleResult.Succeeded) - { - await _userManager.DeleteAsync(user); - return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role."); - } + if (!createResult.Succeeded) + throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member."); - try - { - await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken); - await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken); + var addRoleResult = await _userManager.AddToRoleAsync(user, role!); + if (!addRoleResult.Succeeded) + throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role."); + + await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken); + await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken); + }, + cancellationToken); } - catch + catch (TeamMemberCreateException exception) { - await _userManager.DeleteAsync(user); - throw; + return Failure(exception.Message); } return new CreateTeamMemberOutcomeDTO @@ -391,6 +392,13 @@ public sealed class TeamMemberService : ITeamMemberService private static CreateTeamMemberOutcomeDTO Failure(string error) => new() { Success = false, Error = error }; + private sealed class TeamMemberCreateException : Exception + { + public TeamMemberCreateException(string error) : base(error) + { + } + } + private static TeamMemberOperationOutcomeDTO OperationFailure(string error) => new() { Success = false, Error = error }; } diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs new file mode 100644 index 0000000..43c2d40 --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -0,0 +1,162 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public sealed class TeamMemberCreateTransactionTests +{ + [Fact] + public async Task Create_MidSequencePersistenceFailure_RollsBackAllMemberRows() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (var setup = new SqliteTeamMemberTestDbContext(options)) + await setup.Database.EnsureCreatedAsync(); + + await using (var createScope = new SqliteTeamMemberTestDbContext(options)) + { + var permissionData = new ThrowingAfterPersistPermissionDataService( + new TeamPermissionOverrideDataService(createScope)); + var service = new TeamMemberService( + CreateUserManager(createScope), + CreateRoleManager(createScope), + new UserServiceAreaDataService(createScope), + permissionData, + new UserDataService(createScope), + permissionService: null!); + + var failure = await Record.ExceptionAsync(() => service.CreateAsync( + ValidRequest(), + Admin(), + CancellationToken.None)); + + Assert.IsType(failure); + Assert.Equal("injected mid-sequence failure", failure!.Message); + } + + await using var verify = new SqliteTeamMemberTestDbContext(options); + Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + } + + private static UserManager CreateUserManager(ApplicationDbContext context) => + new( + new UserStore(context), + Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), + new PasswordHasher(), + Array.Empty>(), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + new EmptyServiceProvider(), + NullLogger>.Instance); + + private static RoleManager CreateRoleManager(ApplicationDbContext context) => + new( + new RoleStore(context), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + NullLogger>.Instance); + + private static CreateTeamMemberRequestDTO ValidRequest() => new() + { + Name = "Taylor Dispatcher", + Role = "dispatcher", + Color = "#F59E0B", + Email = "taylor@example.com", + Phone = "555-0100", + ServiceAreas = new[] { "east", "West" }, + PermissionOverrides = new Dictionary + { + ["deleteSites"] = UserPermissionState.Allow + } + }; + + private static ClaimsPrincipal Admin() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test")); + + private sealed class SqliteTeamMemberTestDbContext : ApplicationDbContext + { + public SqliteTeamMemberTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + if (index.GetFilter() is not null) + index.SetFilter(null); + } + + foreach (var property in builder.Model.GetEntityTypes() + .SelectMany(entity => entity.GetProperties()) + .Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[]))) + { + property.ValueGenerated = ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } + + private sealed class ThrowingAfterPersistPermissionDataService : ITeamPermissionOverrideDataService + { + private readonly ITeamPermissionOverrideDataService _inner; + + public ThrowingAfterPersistPermissionDataService(ITeamPermissionOverrideDataService inner) + { + _inner = inner; + } + + public Task GetUserAsync(string userId, CancellationToken cancellationToken) => + _inner.GetUserAsync(userId, cancellationToken); + + public Task SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + CancellationToken cancellationToken) => + _inner.SetOverrideAsync(userId, permissionKey, state, cancellationToken); + + public async Task SetOverridesAsync( + string userId, + IReadOnlyDictionary overrides, + CancellationToken cancellationToken) + { + await _inner.SetOverridesAsync(userId, overrides, cancellationToken); + throw new InvalidOperationException("injected mid-sequence failure"); + } + + public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) => + _inner.ClearOverridesAsync(userId, cancellationToken); + } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} From 5aa3a6f820bc66814398ef9d7996bc21b8b245c8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 03:44:44 -0300 Subject: [PATCH 4/8] test: exercise team member rollback through DI --- .../TeamMemberCreateTransactionTests.cs | 65 ++++++++----------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs index 43c2d40..bc7abe8 100644 --- a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -6,12 +6,17 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.DependencyInjection; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -30,18 +35,27 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); - await using (var createScope = new SqliteTeamMemberTestDbContext(options)) - { - var permissionData = new ThrowingAfterPersistPermissionDataService( - new TeamPermissionOverrideDataService(createScope)); - var service = new TeamMemberService( - CreateUserManager(createScope), - CreateRoleManager(createScope), - new UserServiceAreaDataService(createScope), - permissionData, - new UserDataService(createScope), - permissionService: null!); + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(builder => builder.UseSqlite(connection)); + services.Replace(ServiceDescriptor.Scoped(provider => + new SqliteTeamMemberTestDbContext( + provider.GetRequiredService>()))); + services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + services.AddDataServices(); + services.AddBusinessServices(configuration); + services.AddScoped(); + services.AddScoped(provider => + new ThrowingAfterPersistPermissionDataService( + provider.GetRequiredService())); + await using var serviceProvider = services.BuildServiceProvider(); + await using (var createScope = serviceProvider.CreateAsyncScope()) + { + var service = createScope.ServiceProvider.GetRequiredService(); var failure = await Record.ExceptionAsync(() => service.CreateAsync( ValidRequest(), Admin(), @@ -51,7 +65,8 @@ public sealed class TeamMemberCreateTransactionTests Assert.Equal("injected mid-sequence failure", failure!.Message); } - await using var verify = new SqliteTeamMemberTestDbContext(options); + await using var verifyScope = serviceProvider.CreateAsyncScope(); + var verify = verifyScope.ServiceProvider.GetRequiredService(); Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); @@ -59,26 +74,6 @@ public sealed class TeamMemberCreateTransactionTests Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); } - private static UserManager CreateUserManager(ApplicationDbContext context) => - new( - new UserStore(context), - Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), - new PasswordHasher(), - Array.Empty>(), - Array.Empty>(), - new UpperInvariantLookupNormalizer(), - new IdentityErrorDescriber(), - new EmptyServiceProvider(), - NullLogger>.Instance); - - private static RoleManager CreateRoleManager(ApplicationDbContext context) => - new( - new RoleStore(context), - Array.Empty>(), - new UpperInvariantLookupNormalizer(), - new IdentityErrorDescriber(), - NullLogger>.Instance); - private static CreateTeamMemberRequestDTO ValidRequest() => new() { Name = "Taylor Dispatcher", @@ -155,8 +150,4 @@ public sealed class TeamMemberCreateTransactionTests _inner.ClearOverridesAsync(userId, cancellationToken); } - private sealed class EmptyServiceProvider : IServiceProvider - { - public object? GetService(Type serviceType) => null; - } } From fddb7b790909774039b6ebf6c9909c65219b04c3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 03:49:57 -0300 Subject: [PATCH 5/8] test: verify team member commit and rollback --- .../TeamMemberCreateTransactionTests.cs | 121 ++++++++++++++---- 1 file changed, 99 insertions(+), 22 deletions(-) diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs index bc7abe8..8c5e2bc 100644 --- a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -35,6 +35,85 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); + await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true); + ThrowingAfterPersistPermissionDataService failingPermissionData; + await using (var createScope = serviceProvider.CreateAsyncScope()) + { + var service = createScope.ServiceProvider.GetRequiredService(); + failingPermissionData = (ThrowingAfterPersistPermissionDataService)createScope.ServiceProvider + .GetRequiredService(); + var failure = await Record.ExceptionAsync(() => service.CreateAsync( + ValidRequest(), + Admin(), + CancellationToken.None)); + + Assert.IsType(failure); + Assert.Equal("injected mid-sequence failure", failure!.Message); + } + + Assert.NotNull(failingPermissionData.RowsAtFailure); + var rowsAtFailure = failingPermissionData.RowsAtFailure!.Value; + Assert.Equal(1, rowsAtFailure.Users); + Assert.Equal(1, rowsAtFailure.Roles); + Assert.Equal(1, rowsAtFailure.UserRoles); + Assert.Equal(2, rowsAtFailure.ServiceAreas); + Assert.Equal(1, rowsAtFailure.PermissionOverrides); + + await using var verifyScope = serviceProvider.CreateAsyncScope(); + var verify = verifyScope.ServiceProvider.GetRequiredService(); + Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + } + + [Fact] + public async Task Create_SuccessfulTransaction_CommitsPendingMemberAndAssociations() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (var setup = new SqliteTeamMemberTestDbContext(options)) + await setup.Database.EnsureCreatedAsync(); + + await using var serviceProvider = BuildServiceProvider(connection); + await using (var createScope = serviceProvider.CreateAsyncScope()) + { + var service = createScope.ServiceProvider.GetRequiredService(); + var outcome = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); + + Assert.True(outcome.Success); + Assert.True(outcome.Member!.PendingRegistration); + Assert.Equal("taylor@example.com", outcome.Member.Email); + } + + await using var verifyScope = serviceProvider.CreateAsyncScope(); + var verify = verifyScope.ServiceProvider.GetRequiredService(); + var user = await verify.Users.AsNoTracking().SingleAsync(user => user.Email == "taylor@example.com"); + Assert.True(user.PendingRegistration); + + var role = await verify.Roles.AsNoTracking().SingleAsync(role => role.Name == "Dispatcher"); + Assert.Contains(await verify.UserRoles.AsNoTracking().ToListAsync(), row => row.UserId == user.Id && row.RoleId == role.Id); + + var areas = await verify.UserServiceAreas.AsNoTracking() + .Where(area => area.UserId == user.Id) + .Select(area => area.Area) + .OrderBy(area => area) + .ToListAsync(); + Assert.Equal(new[] { "East", "West" }, areas); + + var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking() + .SingleAsync(permission => permission.UserId == user.Id); + Assert.Equal("deleteSites", permissionOverride.PermissionKey); + } + + private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false) + { var configuration = new ConfigurationBuilder().Build(); var services = new ServiceCollection(); services.AddLogging(); @@ -47,31 +126,17 @@ public sealed class TeamMemberCreateTransactionTests .AddDefaultTokenProviders(); services.AddDataServices(); services.AddBusinessServices(configuration); - services.AddScoped(); - services.AddScoped(provider => - new ThrowingAfterPersistPermissionDataService( - provider.GetRequiredService())); - await using var serviceProvider = services.BuildServiceProvider(); - await using (var createScope = serviceProvider.CreateAsyncScope()) + if (injectFailure) { - var service = createScope.ServiceProvider.GetRequiredService(); - var failure = await Record.ExceptionAsync(() => service.CreateAsync( - ValidRequest(), - Admin(), - CancellationToken.None)); - - Assert.IsType(failure); - Assert.Equal("injected mid-sequence failure", failure!.Message); + services.AddScoped(); + services.AddScoped(provider => + new ThrowingAfterPersistPermissionDataService( + provider.GetRequiredService(), + provider.GetRequiredService())); } - await using var verifyScope = serviceProvider.CreateAsyncScope(); - var verify = verifyScope.ServiceProvider.GetRequiredService(); - Assert.Empty(await verify.Users.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); - Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + return services.BuildServiceProvider(); } private static CreateTeamMemberRequestDTO ValidRequest() => new() @@ -121,12 +186,18 @@ public sealed class TeamMemberCreateTransactionTests private sealed class ThrowingAfterPersistPermissionDataService : ITeamPermissionOverrideDataService { private readonly ITeamPermissionOverrideDataService _inner; + private readonly ApplicationDbContext _context; - public ThrowingAfterPersistPermissionDataService(ITeamPermissionOverrideDataService inner) + public ThrowingAfterPersistPermissionDataService( + ITeamPermissionOverrideDataService inner, + ApplicationDbContext context) { _inner = inner; + _context = context; } + public (int Users, int Roles, int UserRoles, int ServiceAreas, int PermissionOverrides)? RowsAtFailure { get; private set; } + public Task GetUserAsync(string userId, CancellationToken cancellationToken) => _inner.GetUserAsync(userId, cancellationToken); @@ -143,6 +214,12 @@ public sealed class TeamMemberCreateTransactionTests CancellationToken cancellationToken) { await _inner.SetOverridesAsync(userId, overrides, cancellationToken); + RowsAtFailure = ( + await _context.Users.CountAsync(cancellationToken), + await _context.Roles.CountAsync(cancellationToken), + await _context.UserRoles.CountAsync(cancellationToken), + await _context.UserServiceAreas.CountAsync(cancellationToken), + await _context.UserPermissionOverrides.CountAsync(cancellationToken)); throw new InvalidOperationException("injected mid-sequence failure"); } From 89376e99e492f403caacaee60001a6bbb1774384 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:22:13 -0300 Subject: [PATCH 6/8] fix(uplifts): resolve work-order uplifts through owned dispatches (SH-393) Board create left the new primary dispatch with no WorkOrderId, so uplifts created on those work orders were written to a dispatch the work order's uplift reads never resolve: not listed, allowance never consumed, queue WO number blank. The same orphan made ApptDate/vendor patches fail with "A primary dispatch is required". - Board create backfills Dispatch.WorkOrderId after the first save. - Uplift create resolves its dispatch through the read-side scope (non-deleted, owned or linked); otherwise the stable "no primary dispatch" error. - Data-only migration assigns existing orphaned primaries to the single work order naming them primary; idempotent. Co-Authored-By: Claude Opus 5.5 --- ...lBoardCreatedDispatchWorkOrder.Designer.cs | 4213 +++++++++++++++++ ...3_BackfillBoardCreatedDispatchWorkOrder.cs | 43 + .../Implementation/UpliftDataService.cs | 19 + .../Interfaces/IUpliftDataService.cs | 4 + .../WorkOrderBoardCreateService.cs | 10 +- .../Implementation/WorkOrderUpliftService.cs | 28 +- .../WorkOrderBoardCancelServiceTests.cs | 1 + .../WorkOrderUpliftDispatchOwnershipTests.cs | 344 ++ .../WorkOrderUpliftServiceTests.cs | 16 +- 9 files changed, 4656 insertions(+), 22 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs new file mode 100644 index 0000000..12e9c2d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs @@ -0,0 +1,4213 @@ +// +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("20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder")] + partial class SH393_BackfillBoardCreatedDispatchWorkOrder + { + /// + 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("IsAccountOwner") + .HasColumnType("bit"); + + 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("PendingRegistration") + .HasColumnType("bit"); + + b.Property("PendingRegistrationCreatedDate") + .HasColumnType("datetime2"); + + 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("IsAccountOwner") + .IsUnique() + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner") + .HasFilter("IsAccountOwner = 1"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ServiceId"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany("Contacts") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("ServiceAreas"); + + 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/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs new file mode 100644 index 0000000..c0f0242 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + /// SH-393: board-created work orders with a vendor stored their primary dispatch with + /// no WorkOrderId and no DispatchWorkOrders link, so every owned-or-linked read (uplift + /// list, allowance, approval queue WO number, dispatch patches) missed it. Data-only + /// heal: assign the dispatch to the single work order that names it as primary. Rows + /// already owned or linked, and dispatches named primary by more than one work order, + /// are left untouched, so re-running is a no-op. + /// + public partial class SH393_BackfillBoardCreatedDispatchWorkOrder : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE d + SET d.[WorkOrderId] = w.[Id] + FROM [Dispatches] AS d + INNER JOIN [workOrders] AS w ON w.[PrimaryDispatchId] = d.[Id] + WHERE d.[WorkOrderId] IS NULL + AND NOT EXISTS ( + SELECT 1 FROM [DispatchWorkOrders] AS l + WHERE l.[DispatchId] = d.[Id]) + AND ( + SELECT COUNT(*) FROM [workOrders] AS owner + WHERE owner.[PrimaryDispatchId] = d.[Id]) = 1; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The heal is not reversible: healed rows are indistinguishable from dispatches + // that were created with their work order set. + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 1d72cf5..42c338f 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -336,6 +336,25 @@ namespace SeaHaven.DataServices.Implementation .AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken); } + public async Task GetUpliftDispatchForWorkOrderAsync( + int workOrderId, + int? primaryDispatchId, + CancellationToken cancellationToken) + { + var candidates = await _context.Dispatches + .Where(d => + (d.IsDeleted == null || d.IsDeleted == false) + && ( + d.WorkOrderId == workOrderId + || d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId))) + .OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate) + .ThenByDescending(d => d.Id) + .ToListAsync(cancellationToken); + + return candidates.FirstOrDefault(d => d.Id == primaryDispatchId) + ?? candidates.FirstOrDefault(); + } + public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) { return ForWorkOrder(workOrderId) diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 26c968c..ed89f5b 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken); Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + // SH-393: the tracked dispatch a work-order uplift is written to, resolved through the + // same scope the work-order uplift reads use (non-deleted, owned or linked): the + // work order's primary dispatch when it qualifies, otherwise its latest qualifying one. + Task GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken); Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); // Approval queue read contract: set-based per-work-order exposure totals covering // auto-approved and admin-approved uplift amounts (pending, rejected, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index dd2d06f..149a47b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -249,10 +249,14 @@ namespace SeaHaven.Services.Implementation _mutationData.TrackNewWorkOrder(workOrder); var firstOutcome = await _mutationData.SaveAsync(ct); - if (dispatch != null && workOrder.PrimaryDispatchId == null) + // SH-393: the first save already fixes up PrimaryDispatchId from the + // PrimaryDispatch navigation, so the dispatch's own WorkOrderId must be + // backfilled independently. Without it the dispatch belongs to no work order + // and every owned-or-linked read (uplifts, dispatch patches) misses it. + if (dispatch != null) { - workOrder.PrimaryDispatchId = dispatch.Id; - dispatch.WorkOrderId = workOrder.Id; + workOrder.PrimaryDispatchId ??= dispatch.Id; + dispatch.WorkOrderId ??= workOrder.Id; } diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 149aeaa..82511d8 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -104,24 +104,16 @@ namespace SeaHaven.Services.Implementation $"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order"); } - Dispatch? dispatch; - if (workOrder.PrimaryDispatchId is int primaryDispatchId) - { - dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId); - if (dispatch == null) - throw new KeyNotFoundException("Dispatch not found"); - } - else - { - dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken)) - .Where(candidate => candidate.IsDeleted != true) - .OrderByDescending(candidate => candidate.DispatchedAt ?? candidate.CreatedDate) - .ThenByDescending(candidate => candidate.Id) - .FirstOrDefault(); - - if (dispatch == null) - throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); - } + // SH-393: write to a dispatch the work order's uplift reads resolve back to it. + // Loading the primary by id alone accepted soft-deleted, unlinked, or other work + // orders' dispatches, so the request never showed on this work order and never + // consumed its allowance. + var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync( + workOrderId, + workOrder.PrimaryDispatchId, + cancellationToken); + if (dispatch == null) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); if (IsTerminalForUplift(dispatch.Status)) throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index e797a68..8e4fb7b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -442,6 +442,7 @@ public class WorkOrderBoardCancelServiceTests public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs new file mode 100644 index 0000000..840fd28 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs @@ -0,0 +1,344 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +/// +/// SH-393: an uplift created from a work order must land on a dispatch the work order's +/// uplift reads resolve back to it (non-deleted, owned or linked), so the request is +/// listed on that work order, consumes its allowance and carries its WO number in the +/// approval queue. +/// +public sealed class WorkOrderUpliftDispatchOwnershipTests +{ + private const string NoPrimaryDispatchMessage = "Work order has no primary dispatch for uplift requests"; + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" }); + context.SaveChanges(); + return context; + } + + private static WorkOrderBoardCreateService NewBoardCreateService(ApplicationDbContext context) + { + var boardData = new WorkOrderBoardDataService(context); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardCreateService( + boardData, + new WorkOrderBoardMutationDataService(context), + new WorkOrderBoardService(boardData, resolver), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), + new WorkOrderBoardCreateValidation(), + resolver, + new WorkOrderPocDataService(context)); + } + + private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context) + { + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardUpdateService( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(context), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); + } + + private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context) + { + return new WorkOrderUpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + TimeProvider.System, + Options.Create(new ApprovalsOptions + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + })); + } + + private static Task BoardCreateWithVendorAsync( + ApplicationDbContext context, + bool? isAddOn = null) + { + return NewBoardCreateService(context).CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + VendorId = 5, + IsAddOn = isAddOn, + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + } + + private static Task CreateUpliftAsync( + ApplicationDbContext context, + int workOrderId, + decimal amount, + int accountId = 1) + { + return NewUpliftService(context).CreateAsync( + workOrderId, + new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra coil work" }, + WorkOrderAccountTestHelpers.AccountUser(accountId: accountId), + CancellationToken.None); + } + + private static async Task<(int WorkOrderId, int PrimaryDispatchId)> SeedWorkOrderAsync( + ApplicationDbContext context, + Dispatch primary, + params Dispatch[] others) + { + context.Dispatches.Add(primary); + context.Dispatches.AddRange(others); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "SH00001", + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + PrimaryDispatchId = primary.Id, + }); + await context.SaveChangesAsync(); + return (1, primary.Id); + } + + [Fact] + public async Task BoardCreate_WithVendor_PrimaryDispatchBelongsToWorkOrder() + { + await using var context = CreateContext(); + + var row = await BoardCreateWithVendorAsync(context); + + var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId); + Assert.Equal(row.Id, dispatch.WorkOrderId); + } + + [Fact] + public async Task BoardCreatedWorkOrder_PatchApptDate_KeepsPrimaryDispatch() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id); + var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId); + workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + dispatch.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }; + await context.SaveChangesAsync(); + + var patched = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptDate, + Value = "2026-10-05", + WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion), + DispatchVersion = Convert.ToBase64String(dispatch.RowVersion), + }, "actor-1"); + + Assert.Equal(row.PrimaryDispatchId, patched.PrimaryDispatchId); + Assert.Equal(1, await context.Dispatches.CountAsync()); + } + + [Fact] + public async Task BoardCreatedWorkOrder_AutoApprovedUplift_IsListedAndConsumesAllowance() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + + var created = await CreateUpliftAsync(context, row.Id, 400m); + + Assert.Equal("auto_approved", created!.Status); + var listed = await NewUpliftService(context).ListAsync( + row.Id, + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + var uplift = Assert.Single(listed!); + Assert.Equal(created.Id, uplift.Id); + Assert.Equal(400m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None)); + } + + [Fact] + public async Task BoardCreatedWorkOrder_RequestBeyondRemainingAllowance_GoesToPendingWithWorkOrderNumber() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + await CreateUpliftAsync(context, row.Id, 400m); + + var second = await CreateUpliftAsync(context, row.Id, 200m); + + Assert.Equal("pending", second!.Status); + var (_, queue) = await new UpliftDataService(context) + .GetPagedAsync("Pending", null, 1, 25, CancellationToken.None); + var queued = Assert.Single(queue); + Assert.Equal(second.Id, queued.Id); + Assert.Equal(row.Id, queued.WorkOrderId); + Assert.Equal(row.WoNumber, queued.WorkOrderNumber); + } + + [Fact] + public async Task BoardCreatedWorkOrder_OwnerRevokesAutoApprovedUplift() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context); + var created = await CreateUpliftAsync(context, row.Id, 1m); + + var revoked = await NewUpliftService(context).RevokeAsync( + row.Id, + created!.Id, + new RevokeWorkOrderUpliftRequestDto(), + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(0m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None)); + } + + [Fact] + public async Task ExAddOnWorkOrder_AfterScheduleClearsAddOn_UpliftIsListedOnWorkOrder() + { + await using var context = CreateContext(); + var row = await BoardCreateWithVendorAsync(context, isAddOn: true); + Assert.True(row.IsAddOn); + var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id); + workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + await context.SaveChangesAsync(); + var rescheduled = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = DateTime.UtcNow.Date.AddDays(60).ToString("yyyy-MM-dd"), + WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion), + }, "actor-1"); + Assert.False(rescheduled.IsAddOn); + + var created = await CreateUpliftAsync(context, row.Id, 1m); + + var listed = await NewUpliftService(context).ListAsync( + row.Id, + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + Assert.Equal(created!.Id, Assert.Single(listed!).Id); + } + + [Fact] + public async Task Create_SoftDeletedPrimaryDispatch_UsesOwnedLiveDispatch() + { + await using var context = CreateContext(); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true }, + new Dispatch { Id = 11, VendorId = 5, WorkOrderId = 1, NTEAmount = 800m }); + + var created = await CreateUpliftAsync(context, workOrderId, 100m); + + var request = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == created!.Id); + Assert.Equal(11, request.DispatchId); + Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount); + Assert.Equal(900m, (await context.Dispatches.SingleAsync(d => d.Id == 11)).NTEAmount); + } + + [Fact] + public async Task Create_OnlySoftDeletedPrimaryDispatch_IsRejectedWithoutPersisting() + { + await using var context = CreateContext(); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true }); + + var error = await Assert.ThrowsAsync( + () => CreateUpliftAsync(context, workOrderId, 100m)); + + Assert.Equal(NoPrimaryDispatchMessage, error.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Empty(context.WorkOrderAuditLogs); + } + + [Fact] + public async Task Create_PrimaryDispatchOwnedByAnotherWorkOrder_IsNotUsed() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 }); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m }); + + var error = await Assert.ThrowsAsync( + () => CreateUpliftAsync(context, workOrderId, 100m)); + + Assert.Equal(NoPrimaryDispatchMessage, error.Message); + Assert.Empty(context.DispatchUpliftRequests); + Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount); + } + + [Fact] + public async Task Create_UnlinkedPrimaryDispatch_IsRejectedInsteadOfHidingTheUplift() + { + await using var context = CreateContext(); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = null, NTEAmount = 1000m }); + + var error = await Assert.ThrowsAsync( + () => CreateUpliftAsync(context, workOrderId, 100m)); + + Assert.Equal(NoPrimaryDispatchMessage, error.Message); + Assert.Empty(context.DispatchUpliftRequests); + } + + [Fact] + public async Task Create_PrimaryDispatchLinkedThroughDispatchWorkOrders_IsUsed() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 }); + var (workOrderId, _) = await SeedWorkOrderAsync( + context, + new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m }); + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10, WorkOrderId = workOrderId }); + await context.SaveChangesAsync(); + + var created = await CreateUpliftAsync(context, workOrderId, 100m); + + var listed = await NewUpliftService(context).ListAsync( + workOrderId, + WorkOrderAccountTestHelpers.AccountUser(), + CancellationToken.None); + Assert.Equal(created!.Id, Assert.Single(listed!).Id); + } + + [Fact] + public async Task CrossTenant_CreateAndList_AreRejected() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, id: 2, name: "Other Corp"); + var row = await BoardCreateWithVendorAsync(context); + await CreateUpliftAsync(context, row.Id, 1m); + + var created = await CreateUpliftAsync(context, row.Id, 1m, accountId: 2); + var listed = await NewUpliftService(context).ListAsync( + row.Id, + WorkOrderAccountTestHelpers.AccountUser(accountId: 2), + CancellationToken.None); + + Assert.Null(created); + Assert.Null(listed); + Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync()); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 13e5c22..3ebb7c1 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -289,7 +289,21 @@ public sealed class WorkOrderUpliftServiceTests { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); - dispatch.WorkOrderId = null; + // SH-393: an unlinked primary is no longer an uplift target, so the shared-dispatch + // case (owned by another work order, linked to this one) carries the duplicate check. + context.workOrders.Add(new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + }); + dispatch.WorkOrderId = 2; + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + }); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, From ce3b241991c9acd33c8fc1632c4cfb6d0f3b22d2 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:45:00 -0300 Subject: [PATCH 7/8] test(uplifts): prove board-create dispatch ownership persists relationally (SH-393) Co-Authored-By: Claude Opus 5.5 --- .../WorkOrderBoardCreateRelationalTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index 3269309..d91794e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -134,6 +134,72 @@ public class WorkOrderBoardCreateRelationalTests Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync()); } + [Fact] + public async Task Create_WithVendor_PersistsPrimaryDispatchOwnershipForUpliftResolution() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (var context = new SqliteBoardTestDbContext(options)) + { + await context.Database.EnsureCreatedAsync(); + context.Users.Add(new ApplicationUser + { + Id = "actor-1", + UserName = "actor-1", + NormalizedUserName = "ACTOR-1", + Email = "actor@test.local", + NormalizedEmail = "ACTOR@TEST.LOCAL" + }); + await context.SaveChangesAsync(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" }); + await context.SaveChangesAsync(); + } + + WorkOrderBoardRowDto result; + await using (var context = new SqliteBoardTestDbContext(options)) + { + var boardData = new WorkOrderBoardDataService(context); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var service = new WorkOrderBoardCreateService( + boardData, + new WorkOrderBoardMutationDataService(context), + new WorkOrderBoardService(boardData, resolver), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), + new WorkOrderBoardCreateValidation(), + resolver, + new WorkOrderPocDataService(context)); + + result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + VendorId = 5 + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + } + + // SH-393: fresh context, committed state only. The primary dispatch must be owned + // by the work order so the uplift create/read scope resolves it. + await using var verify = new SqliteBoardTestDbContext(options); + var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync(); + Assert.Equal(result.PrimaryDispatchId, dispatch.Id); + Assert.Equal(result.Id, dispatch.WorkOrderId); + + var upliftDispatch = await new UpliftDataService(verify).GetUpliftDispatchForWorkOrderAsync( + result.Id, + result.PrimaryDispatchId, + CancellationToken.None); + Assert.Equal(dispatch.Id, upliftDispatch?.Id); + } + private sealed class SqliteBoardTestDbContext : ApplicationDbContext { public SqliteBoardTestDbContext(DbContextOptions options) From 2be74b261f8ea56e0d7ec9cb03450f7b6cd623d5 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:28:04 -0300 Subject: [PATCH 8/8] test(uplifts): share conflict fixture dispatch through owned and linked work orders After SH-393 the uplift dispatch resolves only through dispatches the work order owns or links through DispatchWorkOrders. Model the shared dispatch that way so the concurrent-insert conflict test exercises the conflict mapping again; assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../WorkOrderUpliftServiceConflictTests.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs index 129e360..5366d8c 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -18,7 +18,25 @@ public sealed class WorkOrderUpliftServiceConflictTests [Fact] public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict() { + // Dispatch 10 is legitimately shared: work order 1 owns it and work order 2 links to + // it through DispatchWorkOrders, so the uplift dispatch resolves to it for both. + var sharedDispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + Status = "Scheduled", + NTEAmount = 1000m, + DispatchWorkOrders = new List + { + new() { DispatchId = 10, WorkOrderId = 2 }, + }, + }; var upliftData = new Mock(); + upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync( + It.IsIn(1, 2), + 10, + It.IsAny())) + .ReturnsAsync(sharedDispatch); var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var savesArrived = 0; var winner = 0; @@ -62,7 +80,7 @@ public sealed class WorkOrderUpliftServiceConflictTests var dispatchData = new Mock(); dispatchData.Setup(x => x.GetByIdAsync(10)) - .ReturnsAsync(new Dispatch { Id = 10, Status = "Scheduled", NTEAmount = 1000m }); + .ReturnsAsync(sharedDispatch); dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask);