From 31d4352a23c8bf40567faa1ca732f2b33d1876e9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 24 Sep 2026 14:34:07 -0300 Subject: [PATCH 1/2] 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/2] 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,