From 31d4352a23c8bf40567faa1ca732f2b33d1876e9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 24 Sep 2026 14:34:07 -0300 Subject: [PATCH 1/6] fix(work-orders): accept a scanned uplift evidence file (SH-388) Dispatchers can attach one evidence file, and create links it only after that document has passed scanning. --- .../WorkOrderRouteContractTests.cs | 6 +- .../WorkOrderUpliftControllerTests.cs | 1 + .../Controllers/WorkOrderDetailController.cs | 50 ++++ .../Helper/VendorDocumentScanWorker.cs | 15 +- .../appsettings.Development.json | 3 + .../Configuration/ServiceOptions.cs | 4 + SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs | 3 + .../Helpers/WorkOrderUpliftContractMapper.cs | 3 + .../WorkOrderUpliftEvidenceService.cs | 226 ++++++++++++++++++ .../Implementation/WorkOrderUpliftService.cs | 34 ++- .../IWorkOrderUpliftEvidenceService.cs | 21 ++ .../WorkOrderBoardCancelServiceTests.cs | 12 +- .../WorkOrderUpliftEvidenceServiceTests.cs | 219 +++++++++++++++++ .../WorkOrderUpliftServiceTests.cs | 89 ++++++- 14 files changed, 674 insertions(+), 12 deletions(-) create mode 100644 SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 6411337..804a77b 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests /// Baseline public endpoint set (verb + action-relative route) that the original single /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route - /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes - /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 54 routes + /// come from 52 actions (Editworkorder and GetWorkorderById each bind two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -66,6 +66,7 @@ public class WorkOrderRouteContractTests "GET {id:int}/comments", "GET {id:int}/detail", "GET {id:int}/uplifts", + "GET {id:int}/uplift-evidence/{documentId:int}", "GET {id:int}/media", "GET {id:int}/media/{mediaId:int}/content", "PATCH {id:int}/board", @@ -91,6 +92,7 @@ public class WorkOrderRouteContractTests "POST {id:int}/completion-doc", "POST {id:int}/media", "POST {id:int}/uplifts", + "POST {id:int}/uplift-evidence", "POST {id:int}/uplifts/{upliftId:int}/cancel", "POST {id:int}/uplifts/{upliftId:int}/revoke", "PUT completion-templates/{id:int}", diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index b3cdbfc..f8068a1 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -22,6 +22,7 @@ public sealed class WorkOrderUpliftControllerTests Mock.Of(), Mock.Of(), upliftService.Object, + Mock.Of(), Mock.Of>()); var claims = new List { new(ClaimTypes.NameIdentifier, "dispatcher-1") }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index d975b32..eb99b1b 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -20,17 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderDetailService _workOrderDetailService; private readonly IWorkOrderCommentService _workOrderCommentService; private readonly IWorkOrderUpliftService _workOrderUpliftService; + private readonly IWorkOrderUpliftEvidenceService _workOrderUpliftEvidenceService; private readonly ILogger _logger; public WorkOrderDetailController( IWorkOrderDetailService workOrderDetailService, IWorkOrderCommentService workOrderCommentService, IWorkOrderUpliftService workOrderUpliftService, + IWorkOrderUpliftEvidenceService workOrderUpliftEvidenceService, ILogger logger) { _workOrderDetailService = workOrderDetailService; _workOrderCommentService = workOrderCommentService; _workOrderUpliftService = workOrderUpliftService; + _workOrderUpliftEvidenceService = workOrderUpliftEvidenceService; _logger = logger; } @@ -178,6 +181,53 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("{id:int}/uplift-evidence")] + [RequestSizeLimit(10_000_000)] + public async Task UploadUpliftEvidence( + int id, + [FromForm] IFormFile file, + CancellationToken cancellationToken) + { + try + { + var uploaded = await _workOrderUpliftEvidenceService.UploadAsync(id, file, User, cancellationToken); + if (uploaded == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(new DataResponse { Status = "Success", Data = uploaded }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be uploaded") }); + } + } + + [HttpGet("{id:int}/uplift-evidence/{documentId:int}")] + public async Task GetUpliftEvidenceStatus( + int id, + int documentId, + CancellationToken cancellationToken) + { + try + { + var status = await _workOrderUpliftEvidenceService.GetStatusAsync(id, documentId, User, cancellationToken); + if (status == null) + return NotFound(new Response { Status = "Error", Message = "Evidence document not found." }); + return Ok(new DataResponse { Status = "Success", Data = status }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be read") }); + } + } + [HttpPost("{id:int}/uplifts/{upliftId:int}/cancel")] public async Task CancelUplift(int id, int upliftId, CancellationToken cancellationToken) { diff --git a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs index b0ac5fd..fe64b6a 100644 --- a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs +++ b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs @@ -1,6 +1,8 @@ using Api.SeaHavenIndustries.Observability; using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Configuration; using Sentry; namespace Api.SeaHavenIndustries.Helper @@ -11,17 +13,20 @@ namespace Api.SeaHavenIndustries.Helper private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; private readonly IHub _sentryHub; + private readonly bool _passWhenScannerUnavailable; public VendorDocumentScanWorker( IServiceScopeFactory scopeFactory, IWebHostEnvironment environment, ILogger logger, - IHub sentryHub) + IHub sentryHub, + IOptions documentOptions) { _scopeFactory = scopeFactory; _environment = environment; _logger = logger; _sentryHub = sentryHub; + _passWhenScannerUnavailable = documentOptions.Value.PassWhenScannerUnavailable; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -54,14 +59,16 @@ namespace Api.SeaHavenIndustries.Helper { var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); var result = await scanner.ScanAsync(path, cancellationToken); - if (result == DocumentScanResult.Unavailable) + if (result == DocumentScanResult.Unavailable && !_passWhenScannerUnavailable) { _logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id); continue; } - document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected"; - document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected"; + var passed = result == DocumentScanResult.Passed + || (result == DocumentScanResult.Unavailable && _passWhenScannerUnavailable); + document.ScanStatus = passed ? "Passed" : "Rejected"; + document.ReviewStatus = passed ? "Processing" : "Rejected"; document.RejectionReason = result == DocumentScanResult.Infected ? "The upload failed malware scanning." : document.RejectionReason; diff --git a/Api.SeaHavenIndustries/appsettings.Development.json b/Api.SeaHavenIndustries/appsettings.Development.json index 93b5c5f..45d3338 100644 --- a/Api.SeaHavenIndustries/appsettings.Development.json +++ b/Api.SeaHavenIndustries/appsettings.Development.json @@ -17,6 +17,9 @@ // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. "ApiKey": "${SENDGRID_API_KEY}" }, + "VendorDocuments": { + "PassWhenScannerUnavailable": true + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://localhost:4200", diff --git a/SeaHaven.Services/Configuration/ServiceOptions.cs b/SeaHaven.Services/Configuration/ServiceOptions.cs index 901dab6..852658d 100644 --- a/SeaHaven.Services/Configuration/ServiceOptions.cs +++ b/SeaHaven.Services/Configuration/ServiceOptions.cs @@ -47,4 +47,8 @@ public sealed class VendorDocumentsOptions public const string SectionName = "VendorDocuments"; public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024; + + // why: local Development has no ClamAV host, so a required scan would leave every + // uplift file Pending. Production leaves this false and keeps the file quarantined. + public bool PassWhenScannerUnavailable { get; set; } } diff --git a/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs index 8a7e683..72d7578 100644 --- a/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs @@ -12,6 +12,8 @@ namespace SeaHaven.Services.DTOs public DateTime? DecidedAt { get; set; } public string DecidedByName { get; set; } = ""; public string DecisionNote { get; set; } = ""; + public int? EvidenceDocumentId { get; set; } + public string EvidenceFileName { get; set; } = ""; } public class WorkOrderUpliftListDto @@ -23,6 +25,7 @@ namespace SeaHaven.Services.DTOs { public decimal Amount { get; set; } public string? Notes { get; set; } + public int? EvidenceDocumentId { get; set; } } public class RevokeWorkOrderUpliftRequestDto diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs index 7596b8d..45fbde0 100644 --- a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs @@ -50,6 +50,8 @@ namespace SeaHaven.Services.Helpers DecidedAt = row.DecidedAt, DecidedByName = decidedByName, DecisionNote = row.DecisionNote ?? "", + EvidenceDocumentId = row.EvidenceDocumentId, + EvidenceFileName = row.EvidenceFileName ?? "", }; } @@ -66,6 +68,7 @@ namespace SeaHaven.Services.Helpers DecidedAt = request.DecidedAt, DecidedByName = decidedByName ?? "", DecisionNote = request.DecisionNote ?? "", + EvidenceDocumentId = request.EvidenceDocumentId, }; public static WorkOrderUpliftDto MapRevokedItem( diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs new file mode 100644 index 0000000..775b4fe --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -0,0 +1,226 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderUpliftEvidenceService : IWorkOrderUpliftEvidenceService + { + private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "application/pdf", "image/jpeg", "image/jpg", "image/png" + }; + + private readonly IWorkOrderDetailDataService _detailData; + private readonly IDispatchDataService _dispatchData; + private readonly IVendorDocumentDataService _documentData; + private readonly IVendorDocumentStoragePort _documentStorage; + private readonly IWorkOrderAccountResolver _accountResolver; + private readonly VendorDocumentsOptions _documentOptions; + + public WorkOrderUpliftEvidenceService( + IWorkOrderDetailDataService detailData, + IDispatchDataService dispatchData, + IVendorDocumentDataService documentData, + IVendorDocumentStoragePort documentStorage, + IWorkOrderAccountResolver accountResolver, + IOptions documentOptions) + { + _detailData = detailData; + _dispatchData = dispatchData; + _documentData = documentData; + _documentStorage = documentStorage; + _accountResolver = accountResolver; + _documentOptions = documentOptions.Value; + } + + public async Task UploadAsync( + int workOrderId, + IFormFile file, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); + if (target == null) + return null; + + var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); + var latest = await _documentData.GetLatestForDispatchAsync(target.Dispatch.Id, cancellationToken); + var version = (latest?.Version ?? 0) + 1; + var storedFileName = $"{target.Dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; + var now = DateTime.UtcNow; + var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable; + var document = new VendorCompletionDocument + { + VendorId = target.Dispatch.VendorId, + DispatchId = target.Dispatch.Id, + WorkOrderId = workOrderId, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = passWithoutScanner ? "Passed" : "Pending", + ReviewStatus = "Processing", + ScannedAt = passWithoutScanner ? now : null, + Version = version, + Purpose = VendorDocumentPurpose.UpliftEvidence, + CreatedDate = now + }; + + await _documentData.AddAsync(document, cancellationToken); + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + DispatchId = target.Dispatch.Id, + UserId = user.FindFirstValue(ClaimTypes.NameIdentifier), + FieldName = $"Dispatch {target.Dispatch.DispatchNumber} Uplift Evidence", + NewValue = document.OriginalFileName, + Action = "uplift_evidence_uploaded", + ActorType = "internal", + CreatedAt = now + }, cancellationToken); + await _documentData.SaveChangesAsync(cancellationToken); + + using var stored = new MemoryStream(bytes); + await _documentStorage.SaveAsync( + target.Dispatch.VendorId, + target.Dispatch.Id, + storedFileName, + stored, + cancellationToken); + + return new UploadCompletionDocumentResultDTO + { + Id = document.Id, + Version = document.Version, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus, + Purpose = document.Purpose + }; + } + + public async Task GetStatusAsync( + int workOrderId, + int documentId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); + if (target == null) + return null; + + var document = await _documentData.GetMetadataForVendorDispatchAsync( + documentId, + target.Dispatch.Id, + target.Dispatch.VendorId, + cancellationToken); + if (document == null || document.Purpose != VendorDocumentPurpose.UpliftEvidence) + return null; + + return new VendorDocumentStatusDTO + { + Id = document.Id, + OriginalFileName = document.OriginalFileName, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus, + Purpose = document.Purpose + }; + } + + private async Task ResolveOpenDispatchAsync( + int workOrderId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountFilter = _accountResolver.ResolveAccountFilter(user); + if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter)) + return null; + + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, + cancellationToken, + accountFilter); + if (workOrder?.PrimaryDispatchId is not int dispatchId) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + + if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) + { + throw new InvalidOperationException( + $"Cannot attach uplift evidence on a '{workOrder.LifecycleStatus}' work order"); + } + + var dispatch = await _dispatchData.GetByIdAsync(dispatchId); + if (dispatch == null) + throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status is "Verified" or "Cancelled" or "Canceled" or "Refused") + throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch"); + + return new OpenDispatch(dispatch); + } + + private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync( + IFormFile file, + CancellationToken cancellationToken) + { + if (file is null || file.Length == 0) + throw new InvalidOperationException("An evidence file is required."); + + if (file.Length > _documentOptions.MaxSizeBytes) + throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); + + var contentType = (file.ContentType ?? string.Empty).Trim(); + if (!AllowedContentTypes.Contains(contentType)) + throw new InvalidOperationException("Only PDF, JPG, and PNG documents are accepted."); + + using var buffer = new MemoryStream(); + await file.CopyToAsync(buffer, cancellationToken); + var bytes = buffer.ToArray(); + if (!MatchesSignature(contentType, bytes)) + throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); + + return (contentType, bytes); + } + + private static bool MatchesSignature(string contentType, byte[] bytes) + { + if (bytes.Length == 0) + return false; + + if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 4 + && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; + } + + if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 + && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; + } + + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) + || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + } + + return false; + } + + private static string SafeExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; + } + + private sealed record OpenDispatch(Dispatch Dispatch); + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 6a2c388..e85c927 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -21,6 +21,7 @@ namespace SeaHaven.Services.Implementation private readonly IUserDataService _userData; private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; + private readonly IVendorDocumentDataService _documentData; public WorkOrderUpliftService( IUpliftDataService upliftData, @@ -29,7 +30,8 @@ namespace SeaHaven.Services.Implementation IWorkOrderAccountResolver accountResolver, IUserDataService userData, TimeProvider timeProvider, - IOptions approvalsOptions) + IOptions approvalsOptions, + IVendorDocumentDataService documentData) { _upliftData = upliftData; _dispatchData = dispatchData; @@ -38,6 +40,7 @@ namespace SeaHaven.Services.Implementation _userData = userData; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; + _documentData = documentData; } public async Task?> ListAsync( @@ -75,6 +78,7 @@ namespace SeaHaven.Services.Implementation workOrderId, request.Amount, notes, + request.EvidenceDocumentId, userId, requesterName, accountFilter, @@ -86,6 +90,7 @@ namespace SeaHaven.Services.Implementation int workOrderId, decimal amount, string notes, + int? evidenceDocumentId, string? userId, string requesterName, int? accountFilter, @@ -114,6 +119,8 @@ namespace SeaHaven.Services.Implementation if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken)) throw new InvalidOperationException("An open uplift request already exists for this work order"); + await RequirePassedEvidenceAsync(evidenceDocumentId, dispatch.Id, dispatch.VendorId, cancellationToken); + var now = _timeProvider.GetUtcNow().UtcDateTime; var current = dispatch.NTEAmount ?? 0m; var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken); @@ -131,6 +138,7 @@ namespace SeaHaven.Services.Implementation current, amount, notes, + evidenceDocumentId, UpliftStatus.NoApprovalRequired, requiredTier: 0, expiresAt: null, @@ -148,6 +156,7 @@ namespace SeaHaven.Services.Implementation current, amount, notes, + evidenceDocumentId, UpliftStatus.Pending, requiredTier: 1, now + _approvalsOptions.EffectiveExpiration, @@ -337,6 +346,7 @@ namespace SeaHaven.Services.Implementation decimal currentNte, decimal amount, string notes, + int? evidenceDocumentId, string status, int requiredTier, DateTime? expiresAt, @@ -358,6 +368,7 @@ namespace SeaHaven.Services.Implementation createdby = userId, ExpiresAt = expiresAt, NotificationStatus = notificationStatus, + EvidenceDocumentId = evidenceDocumentId, }; if (status == UpliftStatus.NoApprovalRequired) { @@ -399,6 +410,27 @@ namespace SeaHaven.Services.Implementation return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId); } + private async Task RequirePassedEvidenceAsync( + int? evidenceDocumentId, + int dispatchId, + int vendorId, + CancellationToken cancellationToken) + { + if (!evidenceDocumentId.HasValue) + return; + + var evidence = await _documentData.GetUpliftEvidenceAsync( + evidenceDocumentId.Value, + dispatchId, + vendorId, + cancellationToken); + if (evidence == null) + { + throw new InvalidOperationException( + "The selected evidence document is not available or has not passed scanning"); + } + } + private async Task ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userId)) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs new file mode 100644 index 0000000..31dc42c --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderUpliftEvidenceService.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderUpliftEvidenceService + { + Task UploadAsync( + int workOrderId, + IFormFile file, + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task GetStatusAsync( + int workOrderId, + int documentId, + ClaimsPrincipal user, + CancellationToken cancellationToken); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index f53f1ff..080f338 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -204,7 +204,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(context)); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData()); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); @@ -265,7 +266,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(context)); var cancel = new WorkOrderBoardCancelService( new ThrowingSaveMutationData(mutationData), boardService, @@ -333,7 +335,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(createContext), new UserDataService(createContext), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(createContext)); var boardData = new WorkOrderBoardDataService(cancelContext); var mutationData = new WorkOrderBoardMutationDataService(cancelContext); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext)); @@ -346,7 +349,8 @@ public class WorkOrderBoardCancelServiceTests WorkOrderAccountTestHelpers.Resolver(cancelContext), new UserDataService(cancelContext), TimeProvider.System, - Options.Create(new ApprovalsOptions())); + Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(cancelContext)); var cancel = new WorkOrderBoardCancelService( mutationData, boardService, diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs new file mode 100644 index 0000000..e0b90ab --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -0,0 +1,219 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderUpliftEvidenceServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedWorkOrderAsync(ApplicationDbContext context) + { + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + context.Users.Add(new ApplicationUser + { + Id = "dispatcher-1", + UserName = "dispatcher-1", + FirstName = "Alex", + LastName = "Dispatcher", + }); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + NTEAmount = 1000m, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + var workOrder = new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + PrimaryDispatchId = 10, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + }; + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + return workOrder; + } + + private static (WorkOrderUpliftEvidenceService Service, MemoryStorage Storage) NewService( + ApplicationDbContext context, + bool passWhenScannerUnavailable = false) + { + var storage = new MemoryStorage(); + var service = new WorkOrderUpliftEvidenceService( + new WorkOrderDetailDataService(context), + new DispatchDataService(context), + new VendorDocumentDataService(context), + storage, + WorkOrderAccountTestHelpers.Resolver(context), + Options.Create(new VendorDocumentsOptions + { + PassWhenScannerUnavailable = passWhenScannerUnavailable, + })); + return (service, storage); + } + + private static ClaimsPrincipal Dispatcher() + => WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1"); + + private static FormFile FormFile(byte[] bytes, string fileName, string contentType) + { + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType, + }; + } + + [Fact] + public async Task UploadAsync_StoresPendingUpliftEvidenceForThePrimaryDispatch() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, storage) = NewService(context); + var pdf = "%PDF-1.4"u8.ToArray(); + + var uploaded = await service.UploadAsync( + workOrder.Id, + FormFile(pdf, "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + var document = Assert.Single(context.VendorCompletionDocuments); + Assert.Equal(document.Id, uploaded!.Id); + Assert.Equal("Pending", uploaded.ScanStatus); + Assert.Null(document.ScannedAt); + Assert.Equal(VendorDocumentPurpose.UpliftEvidence, document.Purpose); + Assert.Equal(1, document.VendorId); + Assert.Equal(10, document.DispatchId); + Assert.Equal("quote.pdf", document.OriginalFileName); + Assert.Null(document.ReplacesDocumentId); + var saved = Assert.Single(storage.Saved); + Assert.Equal((1, 10), (saved.VendorId, saved.DispatchId)); + var audit = Assert.Single(context.WorkOrderAuditLogs); + Assert.Equal("uplift_evidence_uploaded", audit.Action); + Assert.Equal("internal", audit.ActorType); + Assert.Equal("dispatcher-1", audit.UserId); + } + + [Fact] + public async Task UploadAsync_PassWhenScannerUnavailable_StoresPassedEvidence() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, _) = NewService(context, passWhenScannerUnavailable: true); + + var uploaded = await service.UploadAsync( + workOrder.Id, + FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + var document = Assert.Single(context.VendorCompletionDocuments); + Assert.Equal("Passed", uploaded!.ScanStatus); + Assert.Equal("Passed", document.ScanStatus); + Assert.NotNull(document.ScannedAt); + Assert.Equal("Processing", document.ReviewStatus); + } + + [Fact] + public async Task UploadAsync_MismatchedSignature_SavesNothing() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, storage) = NewService(context); + + var ex = await Assert.ThrowsAsync(() => + service.UploadAsync( + workOrder.Id, + FormFile("not a pdf"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None)); + + Assert.Contains("signature", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(context.VendorCompletionDocuments); + Assert.Empty(storage.Saved); + } + + [Fact] + public async Task GetStatusAsync_ReturnsScanStatusOnlyForUpliftEvidenceOnTheDispatch() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, _) = NewService(context); + var uploaded = await service.UploadAsync( + workOrder.Id, + FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + var pending = await service.GetStatusAsync(workOrder.Id, uploaded!.Id, Dispatcher(), CancellationToken.None); + Assert.Equal("Pending", pending!.ScanStatus); + Assert.Equal("quote.pdf", pending.OriginalFileName); + + var stored = Assert.Single(context.VendorCompletionDocuments); + stored.ScanStatus = "Passed"; + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 90, + VendorId = 1, + DispatchId = 10, + WorkOrderId = workOrder.Id, + OriginalFileName = "done.pdf", + StoredFileName = "done.pdf", + ContentType = "application/pdf", + Purpose = "Completion", + ScanStatus = "Passed", + Version = 1, + }); + await context.SaveChangesAsync(); + + var passed = await service.GetStatusAsync(workOrder.Id, uploaded.Id, Dispatcher(), CancellationToken.None); + Assert.Equal("Passed", passed!.ScanStatus); + Assert.Null(await service.GetStatusAsync(workOrder.Id, 90, Dispatcher(), CancellationToken.None)); + } + + private sealed class MemoryStorage : IVendorDocumentStoragePort + { + public List<(int VendorId, int DispatchId, string Name)> Saved { get; } = new(); + + public async Task SaveAsync( + int vendorId, + int dispatchId, + string storedFileName, + Stream content, + CancellationToken cancellationToken) + { + using var buffer = new MemoryStream(); + await content.CopyToAsync(buffer, cancellationToken); + Saved.Add((vendorId, dispatchId, storedFileName)); + } + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) + => throw new NotSupportedException(); + + public void Delete(int vendorId, int dispatchId, string storedFileName) + { + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 7ec8856..92cc5e8 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -38,7 +38,8 @@ public sealed class WorkOrderUpliftServiceTests WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, - Options.Create(NewOptions())); + Options.Create(NewOptions()), + new VendorDocumentDataService(context)); } private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1") @@ -120,7 +121,9 @@ public sealed class WorkOrderUpliftServiceTests Assert.NotNull(created); Assert.Equal("auto_approved", created!.Status); + Assert.Null(created.EvidenceDocumentId); Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + Assert.Null(Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId); } [Fact] @@ -564,4 +567,88 @@ public sealed class WorkOrderUpliftServiceTests service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None)); Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public async Task CreateAsync_PassedEvidenceOnSameDispatch_LinksDocument() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var document = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id); + await context.SaveChangesAsync(); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto + { + Amount = 400m, + Notes = "Quote", + EvidenceDocumentId = document.Id, + }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal(document.Id, created!.EvidenceDocumentId); + Assert.Equal(document.Id, Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId); + } + + [Theory] + [InlineData("other-dispatch")] + [InlineData("wrong-purpose")] + [InlineData("pending-scan")] + public async Task CreateAsync_EvidenceThatDoesNotQualify_RejectsWithoutSaving(string defect) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var document = defect switch + { + "other-dispatch" => SeedEvidence(context, dispatchId: 99, dispatch.VendorId, workOrder.Id), + "wrong-purpose" => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, purpose: "Completion"), + _ => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, scanStatus: "Pending"), + }; + await context.SaveChangesAsync(); + var service = NewService(context); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto + { + Amount = 400m, + Notes = "Quote", + EvidenceDocumentId = document.Id, + }, + Dispatcher(), + CancellationToken.None)); + + Assert.Contains("not available", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(context.DispatchUpliftRequests); + } + + private static VendorCompletionDocument SeedEvidence( + ApplicationDbContext context, + int dispatchId, + int vendorId, + int workOrderId, + string scanStatus = "Passed", + string purpose = "UpliftEvidence") + { + var document = new VendorCompletionDocument + { + Id = 22, + VendorId = vendorId, + DispatchId = dispatchId, + WorkOrderId = workOrderId, + OriginalFileName = "quote.pdf", + StoredFileName = "stored.pdf", + ContentType = "application/pdf", + SizeBytes = 4, + ScanStatus = scanStatus, + ReviewStatus = "Processing", + Purpose = purpose, + Version = 1, + }; + context.VendorCompletionDocuments.Add(document); + return document; + } } From 2f4b78361570b3f90b397b41f48513cbf84db83a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 28 Sep 2026 16:24:26 -0300 Subject: [PATCH 2/6] fix(uplifts): open evidence by work-order access and keep every file Auto-approved evidence was hidden because download checked the approval tier. Viewers of the work order can open it, and a request can store more than one scanned document. --- .../UpliftAdminApprovalTests.cs | 13 +- .../UpliftControllerTests.cs | 8 +- .../UpliftWorkflowTests.cs | 158 +- .../Controllers/UpliftController.cs | 11 +- .../Auth/ApplicationDbContext.cs | 12 + ...1151_SH396_UpliftEvidenceLinks.Designer.cs | 4392 +++++++++++++++++ ...0260928191151_SH396_UpliftEvidenceLinks.cs | 50 + .../ApplicationDbContextModelSnapshot.cs | 34 + .../Models/DispatchUpliftRequestDocument.cs | 15 + .../Models/VendorPortalReadModels.cs | 7 + .../Implementation/UpliftDataService.cs | 111 +- .../Interfaces/IUpliftDataService.cs | 9 +- SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs | 8 + .../Helpers/WorkOrderUpliftContractMapper.cs | 3 + .../WorkOrderUpliftEvidenceAssociation.cs | 47 + .../Implementation/UpliftService.cs | 46 +- .../Implementation/WorkOrderUpliftService.cs | 54 +- .../Interfaces/IUpliftService.cs | 6 +- .../WorkOrderBoardCancelServiceTests.cs | 4 +- .../WorkOrderUpliftServiceTests.cs | 44 + 20 files changed, 4960 insertions(+), 72 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.cs create mode 100644 Data.SeaHavenIndustries/Models/DispatchUpliftRequestDocument.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderUpliftEvidenceAssociation.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs index 7a5d6a7..e4b1c2a 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -36,7 +37,11 @@ public sealed class UpliftAdminApprovalTests new DispatchDataService(context), new NoopDocumentStorage(), TimeProvider.System, - Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions())); + Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()), + accountResolver: new WorkOrderAccountResolver( + new AccountDataService(context), + new LocationDataService(context)), + detailData: new WorkOrderDetailDataService(context)); private static async Task SeedPendingAsync(ApplicationDbContext context, int requiredTier) { @@ -158,7 +163,11 @@ public sealed class UpliftAdminApprovalTests await context.SaveChangesAsync(); var service = NewService(context); - var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None); + var admin = UserWithRoles("Admin"); + ((ClaimsIdentity)admin.Identity!).AddClaim( + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)); + + var result = await service.GetEvidenceForDownloadAsync(admin, 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); result.FileName.Should().Be("invoice.pdf"); diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index 7b3f0ec..f241894 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -322,7 +322,7 @@ public class UpliftControllerTests public async Task DownloadEvidence_NotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 99, It.IsAny())) + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 99, It.IsAny(), It.IsAny())) .ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound()); var controller = NewController(service); @@ -335,7 +335,7 @@ public class UpliftControllerTests public async Task DownloadEvidence_Locked_Returns423() { var service = new Mock(); - service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny(), It.IsAny())) .ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked()); var controller = NewController(service); @@ -351,7 +351,7 @@ public class UpliftControllerTests public async Task DownloadEvidence_Available_ReturnsFile() { var service = new Mock(); - service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny(), It.IsAny())) .ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf")); var controller = NewController(service); @@ -364,7 +364,7 @@ public class UpliftControllerTests public async Task DownloadEvidence_Forbidden_ReturnsSanitized403() { var service = new Mock(); - service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) + service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny(), It.IsAny())) .ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy")); var controller = NewController(service); diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 8c72a7e..e63cb66 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -8,6 +8,7 @@ using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -128,13 +129,39 @@ public sealed class UpliftWorkflowTests Version = 1 }; + private static ClaimsPrincipal WorkOrderViewer(params string[] roles) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, "user-42"), + new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + + private static ClaimsPrincipal OtherAccountUser() + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, "user-99"), + new(SeaHavenClaimTypes.AccountId, "99"), + new(ClaimTypes.Role, "Admin"), + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + private static UpliftService NewUpliftService( ApplicationDbContext context, IVendorDocumentStoragePort storage) => new(new UpliftDataService(context), new DispatchDataService(context), storage, TimeProvider.System, - Microsoft.Extensions.Options.Options.Create(NewOptions())); + Microsoft.Extensions.Options.Options.Create(NewOptions()), + accountResolver: new WorkOrderAccountResolver( + new AccountDataService(context), + new LocationDataService(context)), + detailData: new WorkOrderDetailDataService(context)); private static VendorPortalService NewPortalService( ApplicationDbContext context, @@ -910,7 +937,7 @@ public sealed class UpliftWorkflowTests await context.SaveChangesAsync(); var service = NewUpliftService(context, storage); - var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); result.ContentType.Should().Be("application/pdf"); @@ -918,30 +945,139 @@ public sealed class UpliftWorkflowTests } [Fact] - public async Task GetEvidenceForDownload_ThrowsForbidden_WhenUserLacksRequiredTier() + public async Task GetEvidenceForDownload_ReturnsOk_WhenTierZeroAndCallerCanSeeWorkOrder() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + var storage = new FakeDocumentStorage(); + await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, - RequestedNTE = 4000m, + RequestedNTE = 200m, VendorReason = "reason", - Status = UpliftStatus.Pending, - RequiredTier = 2, + Status = UpliftStatus.NoApprovalRequired, + RequiredTier = 0, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, storage); + + var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Dispatcher"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); + result.FileName.Should().Be("invoice.pdf"); + } + + [Fact] + public async Task GetEvidenceForDownload_ThrowsForbidden_WhenWorkOrderIsOutsideAccount() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + workOrder.AccountId = 1; + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 200m, + VendorReason = "reason", + Status = UpliftStatus.NoApprovalRequired, + RequiredTier = 0, EvidenceDocumentId = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); - var act = () => service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + var act = () => service.GetEvidenceForDownloadAsync(OtherAccountUser(), 1, CancellationToken.None); await act.Should().ThrowAsync(); } + [Fact] + public async Task GetEvidenceForDownload_ReturnsOk_ForASecondLinkedDocument() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + var storage = new FakeDocumentStorage(); + await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None); + await storage.SaveAsync(vendor.Id, dispatch.Id, "photo.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None); + var quote = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id); + quote.Id = 1; + var photo = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id); + photo.Id = 2; + photo.OriginalFileName = "photo.jpg"; + photo.StoredFileName = "photo.bin"; + photo.ContentType = "image/jpeg"; + context.VendorCompletionDocuments.AddRange(quote, photo); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 200m, + VendorReason = "reason", + Status = UpliftStatus.NoApprovalRequired, + RequiredTier = 0, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument + { + UpliftRequestId = 1, + DocumentId = 2 + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, storage); + + var result = await service.GetEvidenceForDownloadAsync( + WorkOrderViewer("Dispatcher"), + 1, + CancellationToken.None, + documentId: 2); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); + result.FileName.Should().Be("photo.jpg"); + } + + [Fact] + public async Task GetEvidenceForDownload_ReturnsNotFound_WhenDocumentIsNotLinked() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + var linked = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id); + linked.Id = 1; + var stranger = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id); + stranger.Id = 2; + stranger.StoredFileName = "other.bin"; + context.VendorCompletionDocuments.AddRange(linked, stranger); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 200m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = 1, + EvidenceDocumentId = 1, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var service = NewUpliftService(context, new FakeDocumentStorage()); + + var result = await service.GetEvidenceForDownloadAsync( + WorkOrderViewer("Approver"), + 1, + CancellationToken.None, + documentId: 2); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); + } + [Fact] public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence() { @@ -961,7 +1097,7 @@ public sealed class UpliftWorkflowTests await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); - var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); } @@ -986,7 +1122,7 @@ public sealed class UpliftWorkflowTests await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); - var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked); } @@ -1011,7 +1147,7 @@ public sealed class UpliftWorkflowTests await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); - var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); } @@ -1042,7 +1178,7 @@ public sealed class UpliftWorkflowTests await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); - var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); + var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); } diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index fcba99f..8ebc684 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -167,11 +167,18 @@ namespace Api.SeaHavenIndustries.Controllers // document id is accepted from the client. 404 covers missing request/evidence and // any non-UpliftEvidence document; 423 covers a scan that has not Passed. [HttpGet("{id:int}/evidence")] - public async Task DownloadEvidence(int id, CancellationToken cancellationToken = default) + public async Task DownloadEvidence( + int id, + [FromQuery] int? documentId = null, + CancellationToken cancellationToken = default) { try { - var result = await _upliftService.GetEvidenceForDownloadAsync(User, id, cancellationToken); + var result = await _upliftService.GetEvidenceForDownloadAsync( + User, + id, + cancellationToken, + documentId); return result.Outcome switch { VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!), diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index f60dd32..7a0bedc 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -43,6 +43,17 @@ namespace Data.SeaHavenIndustries .Property(u => u.RowVersion) .IsRowVersion(); + builder.Entity(entity => + { + entity.HasKey(link => new { link.UpliftRequestId, link.DocumentId }); + entity.HasOne(link => link.UpliftRequest) + .WithMany() + .HasForeignKey(link => link.UpliftRequestId); + entity.HasOne(link => link.Document) + .WithMany() + .HasForeignKey(link => link.DocumentId); + }); + builder.Entity() .HasIndex(l => new { l.WorkOrderId, l.FieldName }) .IsUnique(); @@ -397,6 +408,7 @@ namespace Data.SeaHavenIndustries public DbSet DispatchChecklistItems { get; set; } public DbSet DispatchSignoffs { get; set; } public DbSet DispatchUpliftRequests { get; set; } + public DbSet DispatchUpliftRequestDocuments { get; set; } public DbSet TaskListTemplates { get; set; } public DbSet TaskListTemplateItems { get; set; } public DbSet Services { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs new file mode 100644 index 0000000..1a84de7 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs @@ -0,0 +1,4392 @@ +// +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("20260928191151_SH396_UpliftEvidenceLinks")] + partial class SH396_UpliftEvidenceLinks + { + /// + 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("ExtraSafetyNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + 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.CompletionDocTemplateProcedure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId", "SortOrder"); + + b.ToTable("CompletionDocTemplateProcedures"); + }); + + 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.DispatchUpliftRequestDocument", b => + { + b.Property("UpliftRequestId") + .HasColumnType("int"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.HasKey("UpliftRequestId", "DocumentId"); + + b.HasIndex("DocumentId"); + + b.ToTable("DispatchUpliftRequestDocuments"); + }); + + 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("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailedAttempts") + .HasColumnType("int"); + + 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("Notes") + .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.TeamMemberInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CodeExpiresAt") + .HasColumnType("datetime2"); + + b.Property("CodeFailedAttempts") + .HasColumnType("int"); + + b.Property("CodeHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("CodeSendCount") + .HasColumnType("int"); + + b.Property("CodeSentAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + b.ToTable("TeamMemberInvites"); + }); + + 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.CompletionDocTemplateProcedure", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany("Procedures") + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CompletionDocTemplate"); + }); + + 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.DispatchUpliftRequestDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.DispatchUpliftRequest", "UpliftRequest") + .WithMany() + .HasForeignKey("UpliftRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Document"); + + b.Navigation("UpliftRequest"); + }); + + 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.TeamMemberInvite", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.CompletionDocTemplate", b => + { + b.Navigation("Procedures"); + }); + + 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/20260928191151_SH396_UpliftEvidenceLinks.cs b/Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.cs new file mode 100644 index 0000000..21c55e1 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH396_UpliftEvidenceLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DispatchUpliftRequestDocuments", + columns: table => new + { + UpliftRequestId = table.Column(type: "int", nullable: false), + DocumentId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DispatchUpliftRequestDocuments", x => new { x.UpliftRequestId, x.DocumentId }); + table.ForeignKey( + name: "FK_DispatchUpliftRequestDocuments_DispatchUpliftRequests_UpliftRequestId", + column: x => x.UpliftRequestId, + principalTable: "DispatchUpliftRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_DispatchUpliftRequestDocuments_VendorCompletionDocuments_DocumentId", + column: x => x.DocumentId, + principalTable: "VendorCompletionDocuments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_DispatchUpliftRequestDocuments_DocumentId", + table: "DispatchUpliftRequestDocuments", + column: "DocumentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DispatchUpliftRequestDocuments"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 78c4d11..ba3cda3 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1111,6 +1111,21 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("DispatchUpliftRequests"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequestDocument", b => + { + b.Property("UpliftRequestId") + .HasColumnType("int"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.HasKey("UpliftRequestId", "DocumentId"); + + b.HasIndex("DocumentId"); + + b.ToTable("DispatchUpliftRequestDocuments"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => { b.Property("Id") @@ -3702,6 +3717,25 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("EvidenceDocument"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequestDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.DispatchUpliftRequest", "UpliftRequest") + .WithMany() + .HasForeignKey("UpliftRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Document"); + + b.Navigation("UpliftRequest"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => { b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") diff --git a/Data.SeaHavenIndustries/Models/DispatchUpliftRequestDocument.cs b/Data.SeaHavenIndustries/Models/DispatchUpliftRequestDocument.cs new file mode 100644 index 0000000..4aa27ec --- /dev/null +++ b/Data.SeaHavenIndustries/Models/DispatchUpliftRequestDocument.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class DispatchUpliftRequestDocument + { + public int UpliftRequestId { get; set; } + [ForeignKey(nameof(UpliftRequestId))] + public virtual DispatchUpliftRequest? UpliftRequest { get; set; } + + public int DocumentId { get; set; } + [ForeignKey(nameof(DocumentId))] + public virtual VendorCompletionDocument? Document { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 689f3f0..fd17dd3 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -121,9 +121,16 @@ namespace Data.SeaHavenIndustries public string? NotificationError { get; set; } } + public class UpliftEvidenceFileData + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + public class UpliftForWorkOrderData : UpliftForDispatchData { public string? CreatedByUserId { get; set; } + public List Attachments { get; set; } = new(); } public class UpliftForDispatchData diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 6bee67c..5af47c4 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -302,25 +302,100 @@ namespace SeaHaven.DataServices.Implementation // with the DispatchId equality filter enforces server-side request/document linkage: // a row is returned only when the document is the one linked to this exact request // and dispatch. Soft-deleted requests/documents never resolve. - public async Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) + public async Task GetEvidenceForInternalDownloadAsync( + int upliftRequestId, + CancellationToken cancellationToken, + int? documentId = null) { - return await (from u in _context.DispatchUpliftRequests - where u.Id == upliftRequestId && (u.IsDeleted == null || u.IsDeleted == false) - join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id - where ev.DispatchId == u.DispatchId && (ev.IsDeleted == null || ev.IsDeleted == false) - select new UpliftEvidenceDownloadData - { - Id = u.Id, - DispatchId = u.DispatchId, - VendorId = ev.VendorId, - RequiredTier = u.RequiredTier, - EvidenceDocumentId = u.EvidenceDocumentId, - StoredFileName = ev.StoredFileName, - OriginalFileName = ev.OriginalFileName, - ContentType = ev.ContentType, - Purpose = ev.Purpose, - ScanStatus = ev.ScanStatus - }).FirstOrDefaultAsync(cancellationToken); + var request = await _context.DispatchUpliftRequests + .AsNoTracking() + .FirstOrDefaultAsync( + uplift => uplift.Id == upliftRequestId + && (uplift.IsDeleted == null || uplift.IsDeleted == false), + cancellationToken); + if (request == null) + return null; + + var resolvedDocumentId = documentId ?? request.EvidenceDocumentId; + if (resolvedDocumentId == null) + return null; + + var linkedToRequest = resolvedDocumentId == request.EvidenceDocumentId + || await _context.DispatchUpliftRequestDocuments.AnyAsync( + link => link.UpliftRequestId == request.Id && link.DocumentId == resolvedDocumentId, + cancellationToken); + if (!linkedToRequest) + return null; + + var document = await _context.VendorCompletionDocuments + .AsNoTracking() + .FirstOrDefaultAsync( + candidate => candidate.Id == resolvedDocumentId + && candidate.DispatchId == request.DispatchId + && (candidate.IsDeleted == null || candidate.IsDeleted == false), + cancellationToken); + if (document == null) + return null; + + return new UpliftEvidenceDownloadData + { + Id = request.Id, + DispatchId = request.DispatchId, + VendorId = document.VendorId, + RequiredTier = request.RequiredTier, + EvidenceDocumentId = document.Id, + StoredFileName = document.StoredFileName, + OriginalFileName = document.OriginalFileName, + ContentType = document.ContentType, + Purpose = document.Purpose, + ScanStatus = document.ScanStatus + }; + } + + public async Task>> GetEvidenceFilesAsync( + IReadOnlyCollection upliftRequestIds, + CancellationToken cancellationToken) + { + if (upliftRequestIds.Count == 0) + return new Dictionary>(); + + var rows = await ( + from link in _context.DispatchUpliftRequestDocuments.AsNoTracking() + where upliftRequestIds.Contains(link.UpliftRequestId) + join document in _context.VendorCompletionDocuments.AsNoTracking() + on link.DocumentId equals document.Id + where document.IsDeleted == null || document.IsDeleted == false + orderby link.DocumentId + select new + { + link.UpliftRequestId, + document.Id, + document.OriginalFileName + }).ToListAsync(cancellationToken); + + return rows + .GroupBy(row => row.UpliftRequestId) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group + .Select(row => new UpliftEvidenceFileData + { + Id = row.Id, + Name = row.OriginalFileName ?? "" + }) + .ToList()); + } + + public void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList documentIds) + { + foreach (var documentId in documentIds) + { + _context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument + { + UpliftRequest = request, + DocumentId = documentId + }); + } } public async Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index c160561..3e59432 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -15,7 +15,14 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); // SH-101: server-side join of an uplift request with its linked evidence document. // Returns null when the request, the linked evidence, or the dispatch linkage is absent. - Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken); + Task GetEvidenceForInternalDownloadAsync( + int upliftRequestId, + CancellationToken cancellationToken, + int? documentId = null); + Task>> GetEvidenceFilesAsync( + IReadOnlyCollection upliftRequestIds, + CancellationToken cancellationToken); + void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList documentIds); 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 diff --git a/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs index 72d7578..a5674af 100644 --- a/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs @@ -14,6 +14,13 @@ namespace SeaHaven.Services.DTOs public string DecisionNote { get; set; } = ""; public int? EvidenceDocumentId { get; set; } public string EvidenceFileName { get; set; } = ""; + public List Attachments { get; set; } = new(); + } + + public class WorkOrderUpliftAttachmentDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; } public class WorkOrderUpliftListDto @@ -26,6 +33,7 @@ namespace SeaHaven.Services.DTOs public decimal Amount { get; set; } public string? Notes { get; set; } public int? EvidenceDocumentId { get; set; } + public List? EvidenceDocumentIds { get; set; } } public class RevokeWorkOrderUpliftRequestDto diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs index 58612f3..8ce25c8 100644 --- a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs @@ -53,6 +53,9 @@ namespace SeaHaven.Services.Helpers DecisionNote = row.DecisionNote ?? "", EvidenceDocumentId = row.EvidenceDocumentId, EvidenceFileName = row.EvidenceFileName ?? "", + Attachments = row.Attachments + .Select(file => new WorkOrderUpliftAttachmentDto { Id = file.Id, Name = file.Name }) + .ToList(), }; } diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftEvidenceAssociation.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftEvidenceAssociation.cs new file mode 100644 index 0000000..b1d7107 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftEvidenceAssociation.cs @@ -0,0 +1,47 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderUpliftEvidenceAssociation + { + public static IReadOnlyList Normalize(int? single, IEnumerable? many) + { + var ids = new List(); + if (single is int one && one > 0) + ids.Add(one); + + if (many == null) + return ids; + + foreach (var id in many) + { + if (id > 0 && !ids.Contains(id)) + ids.Add(id); + } + + return ids; + } + + public static async Task RequirePassedAsync( + IVendorDocumentDataService documents, + IReadOnlyList documentIds, + int dispatchId, + int vendorId, + CancellationToken cancellationToken) + { + foreach (var documentId in documentIds) + { + var evidence = await documents.GetUpliftEvidenceAsync( + documentId, + dispatchId, + vendorId, + cancellationToken); + if (evidence == null) + { + throw new InvalidOperationException( + "The selected evidence document is not available or has not passed scanning"); + } + } + } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 450b76b..20bc42b 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -4,6 +4,7 @@ using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -17,6 +18,8 @@ namespace SeaHaven.Services.Implementation private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; private readonly IWorkOrderUpliftService? _workOrderUpliftService; + private readonly IWorkOrderAccountResolver? _accountResolver; + private readonly IWorkOrderDetailDataService? _detailData; public UpliftService( IUpliftDataService upliftData, @@ -24,7 +27,9 @@ namespace SeaHaven.Services.Implementation IVendorDocumentStoragePort documentStorage, TimeProvider timeProvider, IOptions approvalsOptions, - IWorkOrderUpliftService? workOrderUpliftService = null) + IWorkOrderUpliftService? workOrderUpliftService = null, + IWorkOrderAccountResolver? accountResolver = null, + IWorkOrderDetailDataService? detailData = null) { _upliftData = upliftData; _dispatchData = dispatchData; @@ -32,6 +37,8 @@ namespace SeaHaven.Services.Implementation _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; _workOrderUpliftService = workOrderUpliftService; + _accountResolver = accountResolver; + _detailData = detailData; } public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) @@ -330,9 +337,13 @@ namespace SeaHaven.Services.Implementation // service resolves the document by the request's own linkage (no client-supplied // document id or vendor/public path). Only UpliftEvidence documents are exposed; // completion documents resolve to NotFound. A scan that has not Passed is Locked. - public async Task GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken) + public async Task GetEvidenceForDownloadAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken, + int? documentId = null) { - var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken); + var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken, documentId); if (evidence == null || evidence.EvidenceDocumentId == null || !string.Equals(evidence.Purpose, VendorDocumentPurpose.UpliftEvidence, StringComparison.Ordinal)) @@ -340,9 +351,9 @@ namespace SeaHaven.Services.Implementation return UpliftEvidenceDownloadResultDTO.NotFound(); } - if (!UserCanApprove(user, evidence.RequiredTier)) + if (!await CanReadWorkOrderEvidenceAsync(user, id, cancellationToken)) { - throw new UpliftForbiddenException($"Evidence access requires a Tier {evidence.RequiredTier} role"); + throw new UpliftForbiddenException("You are not authorized to view evidence for this uplift request"); } if (!string.Equals(evidence.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)) @@ -356,6 +367,31 @@ namespace SeaHaven.Services.Implementation // Admin passes every permission check regardless of stored configuration, // so the tier-role lists only govern non-Admin approvers. + private async Task CanReadWorkOrderEvidenceAsync( + ClaimsPrincipal user, + int upliftRequestId, + CancellationToken cancellationToken) + { + if (_accountResolver == null || _detailData == null) + return false; + + int? accountFilter; + try + { + accountFilter = _accountResolver.ResolveAccountFilter(user); + } + catch (WorkOrderBoardValidationException) + { + return false; + } + + var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftRequestId, cancellationToken); + if (workOrderId == null) + return false; + + return await _detailData.ExistsAsync(workOrderId.Value, cancellationToken, accountFilter); + } + private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) { if (user.IsInRole("Admin")) return true; diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 0eef95d..e241200 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -60,6 +60,15 @@ namespace SeaHaven.Services.Implementation return null; var rows = await _upliftData.GetForWorkOrderAsync(workOrderId, cancellationToken); + var files = await _upliftData.GetEvidenceFilesAsync( + rows.Select(row => row.Id).ToArray(), + cancellationToken); + foreach (var row in rows) + { + if (files.TryGetValue(row.Id, out var attachments)) + row.Attachments = attachments.ToList(); + } + return rows.Select(WorkOrderUpliftContractMapper.MapItem).ToList(); } @@ -90,7 +99,9 @@ namespace SeaHaven.Services.Implementation workOrderId, request.Amount, notes, - request.EvidenceDocumentId, + WorkOrderUpliftEvidenceAssociation.Normalize( + request.EvidenceDocumentId, + request.EvidenceDocumentIds), userId, requesterName, accountFilter, @@ -132,7 +143,7 @@ namespace SeaHaven.Services.Implementation int workOrderId, decimal amount, string notes, - int? evidenceDocumentId, + IReadOnlyList evidenceDocumentIds, string? userId, string requesterName, int? accountFilter, @@ -173,7 +184,13 @@ namespace SeaHaven.Services.Implementation || await _upliftData.HasActiveAsync(dispatch.Id, cancellationToken)) throw new InvalidOperationException("An open uplift request already exists for this work order"); - await RequirePassedEvidenceAsync(evidenceDocumentId, dispatch.Id, dispatch.VendorId, cancellationToken); + await WorkOrderUpliftEvidenceAssociation.RequirePassedAsync( + _documentData, + evidenceDocumentIds, + dispatch.Id, + dispatch.VendorId, + cancellationToken); + var evidenceDocumentId = evidenceDocumentIds.Count == 0 ? (int?)null : evidenceDocumentIds[0]; var now = _timeProvider.GetUtcNow().UtcDateTime; var current = dispatch.NTEAmount ?? 0m; @@ -193,6 +210,7 @@ namespace SeaHaven.Services.Implementation amount, notes, evidenceDocumentId, + evidenceDocumentIds, UpliftStatus.NoApprovalRequired, requiredTier: 0, expiresAt: null, @@ -209,9 +227,10 @@ namespace SeaHaven.Services.Implementation requesterName, current, amount, - notes, - evidenceDocumentId, - UpliftStatus.Pending, + notes, + evidenceDocumentId, + evidenceDocumentIds, + UpliftStatus.Pending, requiredTier: 1, now + _approvalsOptions.EffectiveExpiration, UpliftNotificationStatus.Pending, @@ -380,6 +399,7 @@ namespace SeaHaven.Services.Implementation decimal amount, string notes, int? evidenceDocumentId, + IReadOnlyList evidenceDocumentIds, string status, int requiredTier, DateTime? expiresAt, @@ -409,6 +429,7 @@ namespace SeaHaven.Services.Implementation dispatch.LastModificationTime = now; } await _upliftData.StageAsync(created, cancellationToken); + _upliftData.StageEvidenceDocuments(created, evidenceDocumentIds); await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null); @@ -443,27 +464,6 @@ namespace SeaHaven.Services.Implementation return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId); } - private async Task RequirePassedEvidenceAsync( - int? evidenceDocumentId, - int dispatchId, - int vendorId, - CancellationToken cancellationToken) - { - if (!evidenceDocumentId.HasValue) - return; - - var evidence = await _documentData.GetUpliftEvidenceAsync( - evidenceDocumentId.Value, - dispatchId, - vendorId, - cancellationToken); - if (evidence == null) - { - throw new InvalidOperationException( - "The selected evidence document is not available or has not passed scanning"); - } - } - private async Task ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userId)) diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs index c84142f..cfd0940 100644 --- a/SeaHaven.Services/Interfaces/IUpliftService.cs +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -15,7 +15,11 @@ namespace SeaHaven.Services.Interfaces // SH-101: internal request-changes route (note + tier authorization + audit). Task RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken); // SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request. - Task GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken); + Task GetEvidenceForDownloadAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken, + int? documentId = null); bool CanApprove(ClaimsPrincipal user, int tier); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index ea07689..6798418 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -458,7 +458,9 @@ public class WorkOrderBoardCancelServiceTests public Task GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); - public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken, int? documentId = null) => throw new NotSupportedException(); + public Task>> GetEvidenceFilesAsync(IReadOnlyCollection upliftRequestIds, CancellationToken cancellationToken) => throw new NotSupportedException(); + public void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList documentIds) => 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(); diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 0cb096a..e6ae9ee 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -904,6 +904,50 @@ public sealed class WorkOrderUpliftServiceTests Assert.Empty(context.DispatchUpliftRequests); } + [Fact] + public async Task CreateAsync_MultipleEvidenceDocuments_LinksEachPassedFile() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var quote = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id); + quote.OriginalFileName = "quote.pdf"; + var photo = new VendorCompletionDocument + { + Id = 23, + VendorId = dispatch.VendorId, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "photo.jpg", + StoredFileName = "stored.jpg", + ContentType = "image/jpeg", + SizeBytes = 4, + ScanStatus = "Passed", + ReviewStatus = "Processing", + Purpose = "UpliftEvidence", + Version = 1, + }; + context.VendorCompletionDocuments.Add(photo); + await context.SaveChangesAsync(); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto + { + Amount = 400m, + Notes = "Quote and photo", + EvidenceDocumentIds = new List { quote.Id, photo.Id }, + }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal(quote.Id, created!.EvidenceDocumentId); + Assert.Equal(2, context.DispatchUpliftRequestDocuments.Count()); + var listed = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None); + var names = Assert.Single(listed!).Attachments.Select(file => file.Name).OrderBy(name => name).ToArray(); + Assert.Equal(new[] { "photo.jpg", "quote.pdf" }, names); + } + private static VendorCompletionDocument SeedEvidence( ApplicationDbContext context, int dispatchId, From b11b66538ae59ff94e6d4ac3f65a7ce0551f62ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:55:07 +0000 Subject: [PATCH 3/6] fix(uplifts): resolve evidence without a primary dispatch Upload and status use the same dispatch create already selects, so a work order with no primary can still attach evidence. Status no longer rejects a terminal work order or dispatch, and create, cancel, and revoke return the stored evidence file name and attachments. Co-authored-by: Arthur Bassi --- .../Helpers/WorkOrderUpliftContractMapper.cs | 27 +++- .../WorkOrderUpliftEvidenceService.cs | 75 ++++++--- .../Implementation/WorkOrderUpliftService.cs | 58 ++++++- .../WorkOrderUpliftEvidenceServiceTests.cs | 148 ++++++++++++++++++ .../WorkOrderUpliftServiceTests.cs | 79 ++++++++++ 5 files changed, 352 insertions(+), 35 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs index 8ce25c8..407448d 100644 --- a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs @@ -6,6 +6,10 @@ using SeaHaven.Services.Implementation; namespace SeaHaven.Services.Helpers { + public sealed record StoredUpliftEvidence( + string? FileName, + IReadOnlyList Attachments); + public static class WorkOrderUpliftContractMapper { public static WorkOrderUpliftSummaryDto MapSummary( @@ -53,13 +57,15 @@ namespace SeaHaven.Services.Helpers DecisionNote = row.DecisionNote ?? "", EvidenceDocumentId = row.EvidenceDocumentId, EvidenceFileName = row.EvidenceFileName ?? "", - Attachments = row.Attachments - .Select(file => new WorkOrderUpliftAttachmentDto { Id = file.Id, Name = file.Name }) - .ToList(), + Attachments = MapAttachments(row.Attachments), }; } - public static WorkOrderUpliftDto MapItem(DispatchUpliftRequest request, string? requestedByName, string? decidedByName) + public static WorkOrderUpliftDto MapItem( + DispatchUpliftRequest request, + string? requestedByName, + string? decidedByName, + StoredUpliftEvidence evidence) => new() { Id = request.Id, @@ -73,18 +79,27 @@ namespace SeaHaven.Services.Helpers DecidedByName = decidedByName ?? "", DecisionNote = request.DecisionNote ?? "", EvidenceDocumentId = request.EvidenceDocumentId, + EvidenceFileName = evidence.FileName ?? "", + Attachments = MapAttachments(evidence.Attachments), }; public static WorkOrderUpliftDto MapRevokedItem( DispatchUpliftRequest request, string? requestedByName, - string decidedByName) + string decidedByName, + StoredUpliftEvidence evidence) { - var dto = MapItem(request, requestedByName, decidedByName); + var dto = MapItem(request, requestedByName, decidedByName, evidence); dto.Status = "revoked"; return dto; } + private static List MapAttachments( + IEnumerable files) + => files + .Select(file => new WorkOrderUpliftAttachmentDto { Id = file.Id, Name = file.Name }) + .ToList(); + public static string ToFrontendStatus(string? backendStatus) { return UpliftStatus.ToCanonical(backendStatus) switch diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs index 775b4fe..a1ac913 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -18,6 +18,7 @@ namespace SeaHaven.Services.Implementation }; private readonly IWorkOrderDetailDataService _detailData; + private readonly IUpliftDataService _upliftData; private readonly IDispatchDataService _dispatchData; private readonly IVendorDocumentDataService _documentData; private readonly IVendorDocumentStoragePort _documentStorage; @@ -26,6 +27,7 @@ namespace SeaHaven.Services.Implementation public WorkOrderUpliftEvidenceService( IWorkOrderDetailDataService detailData, + IUpliftDataService upliftData, IDispatchDataService dispatchData, IVendorDocumentDataService documentData, IVendorDocumentStoragePort documentStorage, @@ -33,6 +35,7 @@ namespace SeaHaven.Services.Implementation IOptions documentOptions) { _detailData = detailData; + _upliftData = upliftData; _dispatchData = dispatchData; _documentData = documentData; _documentStorage = documentStorage; @@ -46,20 +49,29 @@ namespace SeaHaven.Services.Implementation ClaimsPrincipal user, CancellationToken cancellationToken) { - var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); - if (target == null) + var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken); + if (workOrder == null) return null; + var dispatch = await ResolveUpliftDispatchAsync( + workOrderId, + workOrder.PrimaryDispatchId, + cancellationToken); + if (dispatch == null) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + + RejectTerminalTarget(workOrder, dispatch); + var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); - var latest = await _documentData.GetLatestForDispatchAsync(target.Dispatch.Id, cancellationToken); + var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken); var version = (latest?.Version ?? 0) + 1; - var storedFileName = $"{target.Dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; + var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; var now = DateTime.UtcNow; var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable; var document = new VendorCompletionDocument { - VendorId = target.Dispatch.VendorId, - DispatchId = target.Dispatch.Id, + VendorId = dispatch.VendorId, + DispatchId = dispatch.Id, WorkOrderId = workOrderId, OriginalFileName = Path.GetFileName(file.FileName), StoredFileName = storedFileName, @@ -77,9 +89,9 @@ namespace SeaHaven.Services.Implementation await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = workOrderId, - DispatchId = target.Dispatch.Id, + DispatchId = dispatch.Id, UserId = user.FindFirstValue(ClaimTypes.NameIdentifier), - FieldName = $"Dispatch {target.Dispatch.DispatchNumber} Uplift Evidence", + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence", NewValue = document.OriginalFileName, Action = "uplift_evidence_uploaded", ActorType = "internal", @@ -89,8 +101,8 @@ namespace SeaHaven.Services.Implementation using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync( - target.Dispatch.VendorId, - target.Dispatch.Id, + dispatch.VendorId, + dispatch.Id, storedFileName, stored, cancellationToken); @@ -111,14 +123,23 @@ namespace SeaHaven.Services.Implementation ClaimsPrincipal user, CancellationToken cancellationToken) { - var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); - if (target == null) + // A poll reads the document on the dispatch create would pick. It does not + // require that work order or dispatch to still accept a new upload. + var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken); + if (workOrder == null) + return null; + + var dispatch = await ResolveUpliftDispatchAsync( + workOrderId, + workOrder.PrimaryDispatchId, + cancellationToken); + if (dispatch == null) return null; var document = await _documentData.GetMetadataForVendorDispatchAsync( documentId, - target.Dispatch.Id, - target.Dispatch.VendorId, + dispatch.Id, + dispatch.VendorId, cancellationToken); if (document == null || document.Purpose != VendorDocumentPurpose.UpliftEvidence) return null; @@ -133,7 +154,7 @@ namespace SeaHaven.Services.Implementation }; } - private async Task ResolveOpenDispatchAsync( + private async Task FindAccessibleWorkOrderAsync( int workOrderId, ClaimsPrincipal user, CancellationToken cancellationToken) @@ -142,27 +163,31 @@ namespace SeaHaven.Services.Implementation if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter)) return null; - var workOrder = await _detailData.GetWorkOrderForMediaAsync( + return await _detailData.GetWorkOrderForMediaAsync( workOrderId, cancellationToken, accountFilter); - if (workOrder?.PrimaryDispatchId is not int dispatchId) - throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + } + private Task ResolveUpliftDispatchAsync( + int workOrderId, + int? primaryDispatchId, + CancellationToken cancellationToken) + => _upliftData.GetUpliftDispatchForWorkOrderAsync( + workOrderId, + primaryDispatchId, + cancellationToken); + + private static void RejectTerminalTarget(WorkOrder workOrder, Dispatch dispatch) + { if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) { throw new InvalidOperationException( $"Cannot attach uplift evidence on a '{workOrder.LifecycleStatus}' work order"); } - var dispatch = await _dispatchData.GetByIdAsync(dispatchId); - if (dispatch == null) - throw new KeyNotFoundException("Dispatch not found"); - if (dispatch.Status is "Verified" or "Cancelled" or "Canceled" or "Refused") throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch"); - - return new OpenDispatch(dispatch); } private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync( @@ -220,7 +245,5 @@ namespace SeaHaven.Services.Implementation var extension = Path.GetExtension(fileName); return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; } - - private sealed record OpenDispatch(Dispatch Dispatch); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index e241200..9421182 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -284,7 +284,12 @@ namespace SeaHaven.Services.Implementation await _upliftData.SaveChangesAsync(cancellationToken); var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken); - return WorkOrderUpliftContractMapper.MapItem(req, requesterName, await ResolveUserDisplayNameAsync(userId, cancellationToken)); + return await MapStoredRequestAsync( + req, + dispatch, + requesterName, + await ResolveUserDisplayNameAsync(userId, cancellationToken), + cancellationToken); } public async Task RevokeAsync( @@ -375,7 +380,7 @@ namespace SeaHaven.Services.Implementation var decidedByName = await ResolveUserDisplayNameAsync(userId, cancellationToken); var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken); - return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName); + return await MapStoredRequestAsync(req, dispatch, requesterName, decidedByName, cancellationToken); } public async Task WithdrawPendingForWorkOrderAsync( @@ -432,7 +437,54 @@ namespace SeaHaven.Services.Implementation _upliftData.StageEvidenceDocuments(created, evidenceDocumentIds); await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); - return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null); + return await MapStoredRequestAsync(created, dispatch, requesterName, null, cancellationToken); + } + + private async Task MapStoredRequestAsync( + DispatchUpliftRequest request, + Dispatch dispatch, + string? requestedByName, + string? decidedByName, + CancellationToken cancellationToken) + { + var evidence = await LoadStoredEvidenceAsync(request, dispatch, cancellationToken); + if (request.Status == UpliftStatus.Revoked) + { + return WorkOrderUpliftContractMapper.MapRevokedItem( + request, + requestedByName, + decidedByName ?? "", + evidence); + } + + return WorkOrderUpliftContractMapper.MapItem( + request, + requestedByName, + decidedByName, + evidence); + } + + private async Task LoadStoredEvidenceAsync( + DispatchUpliftRequest request, + Dispatch dispatch, + CancellationToken cancellationToken) + { + var files = await _upliftData.GetEvidenceFilesAsync(new[] { request.Id }, cancellationToken); + if (!files.TryGetValue(request.Id, out var attachments)) + attachments = Array.Empty(); + + string? fileName = null; + if (request.EvidenceDocumentId is int documentId) + { + var document = await _documentData.GetMetadataForVendorDispatchAsync( + documentId, + dispatch.Id, + dispatch.VendorId, + cancellationToken); + fileName = document?.OriginalFileName; + } + + return new StoredUpliftEvidence(fileName, attachments); } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs index e0b90ab..1be029d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -2,10 +2,12 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; @@ -62,6 +64,7 @@ public sealed class WorkOrderUpliftEvidenceServiceTests var storage = new MemoryStorage(); var service = new WorkOrderUpliftEvidenceService( new WorkOrderDetailDataService(context), + new UpliftDataService(context), new DispatchDataService(context), new VendorDocumentDataService(context), storage, @@ -186,11 +189,156 @@ public sealed class WorkOrderUpliftEvidenceServiceTests ScanStatus = "Passed", Version = 1, }); + context.Dispatches.Add(new Dispatch + { + Id = 11, + VendorId = 1, + WorkOrderId = workOrder.Id, + DispatchNumber = "DIS-11", + Status = "Scheduled", + }); + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 91, + VendorId = 1, + DispatchId = 11, + WorkOrderId = workOrder.Id, + OriginalFileName = "other.pdf", + StoredFileName = "other.pdf", + ContentType = "application/pdf", + Purpose = VendorDocumentPurpose.UpliftEvidence, + ScanStatus = "Passed", + Version = 1, + }); await context.SaveChangesAsync(); var passed = await service.GetStatusAsync(workOrder.Id, uploaded.Id, Dispatcher(), CancellationToken.None); Assert.Equal("Passed", passed!.ScanStatus); Assert.Null(await service.GetStatusAsync(workOrder.Id, 90, Dispatcher(), CancellationToken.None)); + Assert.Null(await service.GetStatusAsync(workOrder.Id, 91, Dispatcher(), CancellationToken.None)); + } + + [Theory] + [InlineData(LifecycleStatus.Completed, "Scheduled")] + [InlineData(LifecycleStatus.Canceled, "Scheduled")] + [InlineData(LifecycleStatus.Scheduled, "Verified")] + [InlineData(LifecycleStatus.Scheduled, "Cancelled")] + [InlineData(LifecycleStatus.Scheduled, "Canceled")] + [InlineData(LifecycleStatus.Scheduled, "Refused")] + public async Task GetStatusAsync_ReturnsUpliftEvidenceWhenTargetIsTerminal( + LifecycleStatus lifecycle, + string dispatchStatus) + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + var (service, _) = NewService(context); + var uploaded = await service.UploadAsync( + workOrder.Id, + FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + workOrder.LifecycleStatus = lifecycle; + context.Dispatches.Single().Status = dispatchStatus; + await context.SaveChangesAsync(); + + var status = await service.GetStatusAsync(workOrder.Id, uploaded!.Id, Dispatcher(), CancellationToken.None); + + Assert.Equal("Pending", status!.ScanStatus); + Assert.Equal("quote.pdf", status.OriginalFileName); + } + + [Theory] + [InlineData(LifecycleStatus.Completed, "Scheduled", "Completed")] + [InlineData(LifecycleStatus.Scheduled, "Verified", "Verified")] + public async Task UploadAsync_TerminalWorkOrderOrDispatch_Rejects( + LifecycleStatus lifecycle, + string dispatchStatus, + string expected) + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + workOrder.LifecycleStatus = lifecycle; + context.Dispatches.Single().Status = dispatchStatus; + await context.SaveChangesAsync(); + var (service, storage) = NewService(context); + + var ex = await Assert.ThrowsAsync(() => + service.UploadAsync( + workOrder.Id, + FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None)); + + Assert.Contains(expected, ex.Message, StringComparison.Ordinal); + Assert.Empty(context.VendorCompletionDocuments); + Assert.Empty(storage.Saved); + } + + [Fact] + public async Task UploadThenCreate_WithoutPrimaryDispatch_PersistsUpliftOnTheSameDispatch() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + workOrder.PrimaryDispatchId = null; + var role = new IdentityRole("Admin"); + context.Roles.Add(role); + context.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = role.Id, + }); + await context.SaveChangesAsync(); + + var (evidence, _) = NewService(context); + var uploaded = await evidence.UploadAsync( + workOrder.Id, + FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"), + Dispatcher(), + CancellationToken.None); + + var pending = await evidence.GetStatusAsync( + workOrder.Id, + uploaded!.Id, + Dispatcher(), + CancellationToken.None); + Assert.Equal("Pending", pending!.ScanStatus); + + var document = Assert.Single(context.VendorCompletionDocuments); + document.ScanStatus = "Passed"; + await context.SaveChangesAsync(); + + var created = await NewUpliftService(context).CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto + { + Amount = 400m, + Notes = "Quote", + EvidenceDocumentId = uploaded.Id, + }, + Dispatcher(), + CancellationToken.None); + + var request = Assert.Single(context.DispatchUpliftRequests); + Assert.NotNull(created); + Assert.Equal(uploaded.Id, request.EvidenceDocumentId); + Assert.Equal(document.DispatchId, request.DispatchId); + Assert.Equal(10, request.DispatchId); + } + + private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context) + { + return new WorkOrderUpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), + TimeProvider.System, + Options.Create(new ApprovalsOptions { UpliftTier1MaxUsd = 2500m }), + new VendorDocumentDataService(context)); } private sealed class MemoryStorage : IVendorDocumentStoragePort diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index e6ae9ee..70bd6e6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -515,6 +515,43 @@ public sealed class WorkOrderUpliftServiceTests Assert.Equal("cancelled", cancelled!.Status); } + [Fact] + public async Task CancelAsync_ReturnsStoredEvidenceFileNameAndAttachments() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var document = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + createdby = "dispatcher-1", + EvidenceDocumentId = document.Id, + }); + context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument + { + UpliftRequestId = 100, + DocumentId = document.Id, + }); + await context.SaveChangesAsync(); + + var cancelled = await NewService(context).CancelAsync( + workOrder.Id, + 100, + Dispatcher(), + CancellationToken.None); + + Assert.Equal("cancelled", cancelled!.Status); + Assert.Equal("quote.pdf", cancelled.EvidenceFileName); + var attachment = Assert.Single(cancelled.Attachments); + Assert.Equal(document.Id, attachment.Id); + Assert.Equal("quote.pdf", attachment.Name); + } + [Fact] public async Task RevokeAsync_ApprovedRequiresAdminReason() { @@ -761,6 +798,40 @@ public sealed class WorkOrderUpliftServiceTests .SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None)); } + [Fact] + public async Task RevokeAsync_ReturnsStoredEvidenceFileNameAndAttachments() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var document = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id); + await context.SaveChangesAsync(); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto + { + Amount = 400m, + Notes = "Quote", + EvidenceDocumentId = document.Id, + }, + Dispatcher(), + CancellationToken.None); + + var revoked = await service.RevokeAsync( + workOrder.Id, + created!.Id, + new RevokeWorkOrderUpliftRequestDto(), + DispatcherRoleOnly(), + CancellationToken.None); + + Assert.Equal("revoked", revoked!.Status); + Assert.Equal("quote.pdf", revoked.EvidenceFileName); + var attachment = Assert.Single(revoked.Attachments); + Assert.Equal(document.Id, attachment.Id); + Assert.Equal("quote.pdf", attachment.Name); + } + [Fact] public async Task RevokeAsync_AdminOwnerRevokingAutoApproved_IsForbiddenAndKeepsAllowanceConsumed() { @@ -868,6 +939,10 @@ public sealed class WorkOrderUpliftServiceTests CancellationToken.None); Assert.Equal(document.Id, created!.EvidenceDocumentId); + Assert.Equal("quote.pdf", created.EvidenceFileName); + var attachment = Assert.Single(created.Attachments); + Assert.Equal(document.Id, attachment.Id); + Assert.Equal("quote.pdf", attachment.Name); Assert.Equal(document.Id, Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId); } @@ -942,6 +1017,10 @@ public sealed class WorkOrderUpliftServiceTests CancellationToken.None); Assert.Equal(quote.Id, created!.EvidenceDocumentId); + Assert.Equal("quote.pdf", created.EvidenceFileName); + Assert.Equal( + new[] { "quote.pdf", "photo.jpg" }, + created.Attachments.Select(file => file.Name).ToArray()); Assert.Equal(2, context.DispatchUpliftRequestDocuments.Count()); var listed = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None); var names = Assert.Single(listed!).Attachments.Select(file => file.Name).OrderBy(name => name).ToArray(); From 1daac954a322ad55525698cfd3f1dfe1aaf10058 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:58:51 +0000 Subject: [PATCH 4/6] fix(uplifts): return stored evidence after a concurrent create Map the saved request from the evidence-file lookup, and teach the shared-dispatch conflict test to return that lookup so a winning insert still produces a response. Co-authored-by: Arthur Bassi --- .../WorkOrderUpliftServiceConflictTests.cs | 4 ++++ SeaHaven.Services/Implementation/WorkOrderUpliftService.cs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs index 9325b3d..d9a503c 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -56,6 +56,10 @@ public sealed class WorkOrderUpliftServiceConflictTests .ReturnsAsync(0m); upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); + upliftData.Setup(x => x.GetEvidenceFilesAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary>()); upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) .Returns(async () => { diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 9421182..0e3af52 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -469,7 +469,8 @@ namespace SeaHaven.Services.Implementation Dispatch dispatch, CancellationToken cancellationToken) { - var files = await _upliftData.GetEvidenceFilesAsync(new[] { request.Id }, cancellationToken); + var files = await _upliftData.GetEvidenceFilesAsync(new[] { request.Id }, cancellationToken) + ?? new Dictionary>(); if (!files.TryGetValue(request.Id, out var attachments)) attachments = Array.Empty(); From 98bd45423da3c7193fd42021ab87ba0913443a3a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 14:31:04 +0000 Subject: [PATCH 5/6] fix(uplifts): count evidence photos and require request permission Uplift evidence uploads now share the work-order photo limit and the RequestUplifts check used when creating an uplift. Co-authored-by: Arthur Bassi --- .../WorkOrderUpliftControllerTests.cs | 33 +++++++- .../Controllers/WorkOrderDetailController.cs | 7 ++ .../WorkOrderUpliftRequestAuthorization.cs | 42 ++++++++++ .../WorkOrderUpliftEvidenceService.cs | 37 +++++++++ .../Implementation/WorkOrderUpliftService.cs | 32 ++------ .../WorkOrderUpliftEvidenceServiceTests.cs | 78 +++++++++++++++++-- 6 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderUpliftRequestAuthorization.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index c9465b9..0dec122 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -18,12 +18,18 @@ public sealed class WorkOrderUpliftControllerTests private static WorkOrderDetailController NewController( Mock upliftService, params string[] roles) + => NewController(upliftService, new Mock(), roles); + + private static WorkOrderDetailController NewController( + Mock upliftService, + Mock evidenceService, + params string[] roles) { var controller = new WorkOrderDetailController( Mock.Of(), Mock.Of(), upliftService.Object, - Mock.Of(), + evidenceService.Object, Mock.Of>()); var claims = new List { new(ClaimTypes.NameIdentifier, "dispatcher-1") }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); @@ -103,6 +109,31 @@ public sealed class WorkOrderUpliftControllerTests response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage); } + [Fact] + public async Task UploadUpliftEvidence_RequestPermissionDenied_ReturnsExact403Message() + { + var evidence = new Mock(); + evidence.Setup(x => x.UploadAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage)); + + var controller = NewController(new Mock(), evidence, "Scheduler"); + var file = new FormFile(new MemoryStream(new byte[] { 0xFF, 0xD8, 0xFF }), 0, 3, "file", "quote.jpg") + { + Headers = new HeaderDictionary(), + ContentType = "image/jpeg", + }; + var result = await controller.UploadUpliftEvidence(7, file, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + [Fact] public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index 7d032fb..0ad2f8c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -206,6 +206,13 @@ namespace Api.SeaHavenIndustries.Controllers { return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); } + catch (UpliftForbiddenException ex) + { + var message = ex.Message == UpliftForbiddenException.RequestUpliftsDeniedMessage + ? UpliftForbiddenException.RequestUpliftsDeniedMessage + : _logger.Sanitize(ex, "You are not authorized to perform this action"); + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = message }); + } catch (InvalidOperationException ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be uploaded") }); diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftRequestAuthorization.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftRequestAuthorization.cs new file mode 100644 index 0000000..3cb354a --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftRequestAuthorization.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Shared gate for creating an uplift and uploading its evidence. Both paths load the + /// caller's database role and overrides, then require . + /// + public static class WorkOrderUpliftRequestAuthorization + { + public static async Task EnsureCanRequestUpliftAsync( + ITeamPermissionOverrideDataService permissionUsers, + ITeamPermissionPolicy permissionPolicy, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + var permissionUser = string.IsNullOrWhiteSpace(userId) + ? null + : await permissionUsers.GetUserAsync(userId, cancellationToken); + + if (permissionUser is null) + { + throw new UpliftForbiddenException( + UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + + if (!permissionPolicy.IsAllowed( + permissionUser.RoleName, + TeamPermissionKeys.RequestUplifts, + permissionUser.Overrides)) + { + throw new UpliftForbiddenException( + UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs index a1ac913..be9b344 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -23,6 +24,8 @@ namespace SeaHaven.Services.Implementation private readonly IVendorDocumentDataService _documentData; private readonly IVendorDocumentStoragePort _documentStorage; private readonly IWorkOrderAccountResolver _accountResolver; + private readonly ITeamPermissionOverrideDataService _permissionOverrideData; + private readonly ITeamPermissionPolicy _permissionPolicy; private readonly VendorDocumentsOptions _documentOptions; public WorkOrderUpliftEvidenceService( @@ -32,6 +35,8 @@ namespace SeaHaven.Services.Implementation IVendorDocumentDataService documentData, IVendorDocumentStoragePort documentStorage, IWorkOrderAccountResolver accountResolver, + ITeamPermissionOverrideDataService permissionOverrideData, + ITeamPermissionPolicy permissionPolicy, IOptions documentOptions) { _detailData = detailData; @@ -40,6 +45,8 @@ namespace SeaHaven.Services.Implementation _documentData = documentData; _documentStorage = documentStorage; _accountResolver = accountResolver; + _permissionOverrideData = permissionOverrideData; + _permissionPolicy = permissionPolicy; _documentOptions = documentOptions.Value; } @@ -49,6 +56,12 @@ namespace SeaHaven.Services.Implementation ClaimsPrincipal user, CancellationToken cancellationToken) { + await WorkOrderUpliftRequestAuthorization.EnsureCanRequestUpliftAsync( + _permissionOverrideData, + _permissionPolicy, + user, + cancellationToken); + var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken); if (workOrder == null) return null; @@ -63,6 +76,7 @@ namespace SeaHaven.Services.Implementation RejectTerminalTarget(workOrder, dispatch); var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); + await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, cancellationToken); var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken); var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; @@ -190,6 +204,29 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch"); } + private async Task EnsureWithinMediaCountsAsync( + int workOrderId, + string contentType, + string fileName, + CancellationToken cancellationToken) + { + // Same per-work-order photo/video count as the vendor uplift-evidence upload: + // dispatcher attachments plus every active vendor document, with no purpose filter. + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, + null, + cancellationToken); + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, + cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount( + WorkOrderMediaContract.ResolveKind(contentType, fileName), + attachmentUrls, + vendorTypes); + if (countMessage != null) + throw new InvalidOperationException(countMessage); + } + private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync( IFormFile file, CancellationToken cancellationToken) diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 0e3af52..8153673 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Options; using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; -using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; @@ -81,7 +80,11 @@ namespace SeaHaven.Services.Implementation if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; - await EnsureCanRequestUpliftAsync(user, cancellationToken); + await WorkOrderUpliftRequestAuthorization.EnsureCanRequestUpliftAsync( + _permissionOverrideData, + _permissionPolicy, + user, + cancellationToken); if (request.Amount <= 0) throw new InvalidOperationException("Uplift amount must be greater than zero"); @@ -114,31 +117,6 @@ namespace SeaHaven.Services.Implementation } } - private async Task EnsureCanRequestUpliftAsync( - ClaimsPrincipal user, - CancellationToken cancellationToken) - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - var permissionUser = string.IsNullOrWhiteSpace(userId) - ? null - : await _permissionOverrideData.GetUserAsync(userId, cancellationToken); - - if (permissionUser is null) - { - throw new UpliftForbiddenException( - UpliftForbiddenException.RequestUpliftsDeniedMessage); - } - - if (!_permissionPolicy.IsAllowed( - permissionUser.RoleName, - TeamPermissionKeys.RequestUplifts, - permissionUser.Overrides)) - { - throw new UpliftForbiddenException( - UpliftForbiddenException.RequestUpliftsDeniedMessage); - } - } - private async Task CreateLockedAsync( int workOrderId, decimal amount, diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs index 1be029d..60a5e14 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -24,7 +24,9 @@ public sealed class WorkOrderUpliftEvidenceServiceTests return new ApplicationDbContext(options); } - private static async Task SeedWorkOrderAsync(ApplicationDbContext context) + private static async Task SeedWorkOrderAsync( + ApplicationDbContext context, + string role = "Admin") { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -34,6 +36,13 @@ public sealed class WorkOrderUpliftEvidenceServiceTests FirstName = "Alex", LastName = "Dispatcher", }); + var identityRole = new IdentityRole(role); + context.Roles.Add(identityRole); + context.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = identityRole.Id, + }); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); context.Dispatches.Add(new Dispatch { @@ -69,6 +78,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests new VendorDocumentDataService(context), storage, WorkOrderAccountTestHelpers.Resolver(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), Options.Create(new VendorDocumentsOptions { PassWhenScannerUnavailable = passWhenScannerUnavailable, @@ -158,6 +169,64 @@ public sealed class WorkOrderUpliftEvidenceServiceTests Assert.Empty(storage.Saved); } + [Fact] + public async Task UploadAsync_EleventhPhoto_RejectsAndPersistsNothing() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context); + for (var i = 0; i < 10; i++) + { + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = 1, + DispatchId = 10, + WorkOrderId = workOrder.Id, + OriginalFileName = $"photo-{i}.jpg", + StoredFileName = $"photo-{i}.jpg", + ContentType = "image/jpeg", + Purpose = "Completion", + ScanStatus = "Passed", + Version = 1, + }); + } + + await context.SaveChangesAsync(); + var (service, storage) = NewService(context); + + var ex = await Assert.ThrowsAsync(() => + service.UploadAsync( + workOrder.Id, + FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"), + Dispatcher(), + CancellationToken.None)); + + Assert.Equal("A work order can have at most 10 photos.", ex.Message); + Assert.Equal(10, context.VendorCompletionDocuments.Count()); + Assert.DoesNotContain(context.VendorCompletionDocuments, document => document.Purpose == VendorDocumentPurpose.UpliftEvidence); + Assert.Empty(context.WorkOrderAuditLogs); + Assert.Empty(storage.Saved); + } + + [Fact] + public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing() + { + await using var context = CreateContext(); + var workOrder = await SeedWorkOrderAsync(context, role: "Scheduler"); + var (service, storage) = NewService(context); + + var ex = await Assert.ThrowsAsync(() => + service.UploadAsync( + workOrder.Id, + FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"), + WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"), + CancellationToken.None)); + + Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, ex.Message); + Assert.Empty(context.VendorCompletionDocuments); + Assert.Empty(context.WorkOrderAuditLogs); + Assert.Empty(storage.Saved); + } + [Fact] public async Task GetStatusAsync_ReturnsScanStatusOnlyForUpliftEvidenceOnTheDispatch() { @@ -281,13 +350,6 @@ public sealed class WorkOrderUpliftEvidenceServiceTests await using var context = CreateContext(); var workOrder = await SeedWorkOrderAsync(context); workOrder.PrimaryDispatchId = null; - var role = new IdentityRole("Admin"); - context.Roles.Add(role); - context.UserRoles.Add(new IdentityUserRole - { - UserId = "dispatcher-1", - RoleId = role.Id, - }); await context.SaveChangesAsync(); var (evidence, _) = NewService(context); From 93dda6042560d4af8c308fadd471805d1acf0db4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 15:01:59 +0000 Subject: [PATCH 6/6] fix(uplifts): serialize evidence photo count with the work-order lock Overlapping evidence uploads could both read nine photos and both save. The count and the insert now run under ExecuteWorkOrderMutationAsync, the same gate the dispatcher media path uses. Co-authored-by: Arthur Bassi --- .../WorkOrderUpliftEvidenceService.cs | 74 +++++++----- .../WorkOrderUpliftEvidenceServiceTests.cs | 111 +++++++++++++++++- 2 files changed, 151 insertions(+), 34 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs index be9b344..0c46148 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -76,42 +76,54 @@ namespace SeaHaven.Services.Implementation RejectTerminalTarget(workOrder, dispatch); var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); - await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, cancellationToken); var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken); var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; - var now = DateTime.UtcNow; var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable; - var document = new VendorCompletionDocument - { - VendorId = dispatch.VendorId, - DispatchId = dispatch.Id, - WorkOrderId = workOrderId, - OriginalFileName = Path.GetFileName(file.FileName), - StoredFileName = storedFileName, - ContentType = contentType, - SizeBytes = bytes.Length, - ScanStatus = passWithoutScanner ? "Passed" : "Pending", - ReviewStatus = "Processing", - ScannedAt = passWithoutScanner ? now : null, - Version = version, - Purpose = VendorDocumentPurpose.UpliftEvidence, - CreatedDate = now - }; + var actorId = user.FindFirstValue(ClaimTypes.NameIdentifier); - await _documentData.AddAsync(document, cancellationToken); - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = workOrderId, - DispatchId = dispatch.Id, - UserId = user.FindFirstValue(ClaimTypes.NameIdentifier), - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence", - NewValue = document.OriginalFileName, - Action = "uplift_evidence_uploaded", - ActorType = "internal", - CreatedAt = now - }, cancellationToken); - await _documentData.SaveChangesAsync(cancellationToken); + // Photo/video caps are a work-order aggregate shared with dispatcher media + // and the vendor portal, so the count and the insert share that mutation lock. + var document = await _upliftData.ExecuteWorkOrderMutationAsync( + workOrderId, + async ct => + { + await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, ct); + + var now = DateTime.UtcNow; + var created = new VendorCompletionDocument + { + VendorId = dispatch.VendorId, + DispatchId = dispatch.Id, + WorkOrderId = workOrderId, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = passWithoutScanner ? "Passed" : "Pending", + ReviewStatus = "Processing", + ScannedAt = passWithoutScanner ? now : null, + Version = version, + Purpose = VendorDocumentPurpose.UpliftEvidence, + CreatedDate = now + }; + + await _documentData.AddAsync(created, ct); + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + DispatchId = dispatch.Id, + UserId = actorId, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence", + NewValue = created.OriginalFileName, + Action = "uplift_evidence_uploaded", + ActorType = "internal", + CreatedAt = now + }, ct); + await _documentData.SaveChangesAsync(ct); + return created; + }, + cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync( diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs index 60a5e14..3c0e506 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -26,7 +26,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests private static async Task SeedWorkOrderAsync( ApplicationDbContext context, - string role = "Admin") + string role = "Admin", + int workOrderId = 1) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -48,14 +49,14 @@ public sealed class WorkOrderUpliftEvidenceServiceTests { Id = 10, VendorId = 1, - WorkOrderId = 1, + WorkOrderId = workOrderId, NTEAmount = 1000m, DispatchNumber = "DIS-10", Status = "Scheduled", }); var workOrder = new WorkOrder { - Id = 1, + Id = workOrderId, InternalWONumber = "10000000001", PrimaryDispatchId = 10, AccountId = 1, @@ -207,6 +208,110 @@ public sealed class WorkOrderUpliftEvidenceServiceTests Assert.Empty(storage.Saved); } + [Fact] + public async Task UploadAsync_ConcurrentPhotos_PersistOnlyTheLastOpenSlot() + { + // Distinct id: the mutation gate is process-wide per work order. Nine photos + // leave one slot. Both uploads must wait on that gate; otherwise each reads 9 + // and both persist. + const int workOrderId = 388_171; + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + await using (var seed = new ApplicationDbContext(options)) + { + await SeedWorkOrderAsync(seed, workOrderId: workOrderId); + for (var i = 0; i < 9; i++) + { + seed.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = 1, + DispatchId = 10, + WorkOrderId = workOrderId, + OriginalFileName = $"photo-{i}.jpg", + StoredFileName = $"photo-{i}.jpg", + ContentType = "image/jpeg", + Purpose = "Completion", + ScanStatus = "Passed", + Version = 1, + }); + } + + await seed.SaveChangesAsync(); + } + + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var holderContext = new ApplicationDbContext(options); + var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync( + workOrderId, + async _ => + { + held.SetResult(); + await release.Task; + return 0; + }, + CancellationToken.None); + await held.Task; + + await using var firstContext = new ApplicationDbContext(options); + await using var secondContext = new ApplicationDbContext(options); + var (first, firstStorage) = NewService(firstContext); + var (second, secondStorage) = NewService(secondContext); + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }; + var firstUpload = first.UploadAsync( + workOrderId, + FormFile(jpeg, "a.jpg", "image/jpeg"), + Dispatcher(), + CancellationToken.None); + var secondUpload = second.UploadAsync( + workOrderId, + FormFile(jpeg, "b.jpg", "image/jpeg"), + Dispatcher(), + CancellationToken.None); + + try + { + await Task.Delay(200); + Assert.False(firstUpload.IsCompleted); + Assert.False(secondUpload.IsCompleted); + await using var during = new ApplicationDbContext(options); + Assert.Equal(9, await during.VendorCompletionDocuments.CountAsync()); + } + finally + { + release.TrySetResult(); + } + + await holder; + var outcomes = await Task.WhenAll(Observe(firstUpload), Observe(secondUpload)); + + await using var verify = new ApplicationDbContext(options); + Assert.Equal(10, await verify.VendorCompletionDocuments.CountAsync()); + Assert.Equal( + 1, + await verify.VendorCompletionDocuments.CountAsync( + document => document.Purpose == VendorDocumentPurpose.UpliftEvidence)); + Assert.Equal(1, outcomes.Count(outcome => outcome is null)); + var rejected = Assert.Single(outcomes.OfType()); + Assert.Equal("A work order can have at most 10 photos.", rejected.Message); + Assert.Equal(1, firstStorage.Saved.Count + secondStorage.Saved.Count); + + static async Task Observe(Task upload) + { + try + { + await upload; + return null; + } + catch (Exception ex) + { + return ex; + } + } + } + [Fact] public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing() {