From 7c097c27508adb0983b5ce17ceea3fb49829a752 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:00:22 -0300 Subject: [PATCH 1/3] feat(completion-templates): author templates with safety note and ordered procedures Adds an extra safety note and an ordered procedure list to completion document templates, name search, creator and last-updated audit fields, a tenant-scoped count of open work orders that depend on a template, and a delete that unlinks Services while they keep requiring a document. Writes are gated by the create/edit/delete completion template team permissions instead of the Admin role. --- .../WorkOrderRouteContractTests.cs | 5 +- .../WorkOrderCompletionController.cs | 72 +- .../Auth/ApplicationDbContext.cs | 18 + ...ompletionDocTemplateProcedures.Designer.cs | 4263 +++++++++++++++++ ...5135234_CompletionDocTemplateProcedures.cs | 59 + .../ApplicationDbContextModelSnapshot.cs | 50 + .../Models/CompletionDocTemplate.cs | 22 + .../CompletionDocTemplateDataService.cs | 167 +- .../WorkOrderCompletionDataService.cs | 3 - .../Interfaces/CompletionDocTemplateModels.cs | 26 + .../IWorkOrderCompletionDataService.cs | 1 - .../Interfaces/IWorkOrderDetailDataService.cs | 42 +- SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 43 +- .../CompletionDocTemplateService.cs | 259 + .../WorkOrderCompletionService.cs | 70 - .../ICompletionDocTemplateService.cs | 40 + .../Interfaces/IWorkOrderCompletionService.cs | 6 - .../CompletionDocTemplateServiceTests.cs | 641 +++ 18 files changed, 5634 insertions(+), 153 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.cs create mode 100644 SeaHaven.DataServices/Interfaces/CompletionDocTemplateModels.cs create mode 100644 SeaHaven.Services/Implementation/CompletionDocTemplateService.cs create mode 100644 SeaHaven.Services/Interfaces/ICompletionDocTemplateService.cs create mode 100644 SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index fa023ea..6c044fb 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 51 actions (Editworkorder binds two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 53 routes + /// come from 52 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -60,6 +60,7 @@ public class WorkOrderRouteContractTests "GET board/search", "GET completion-templates", "GET completion-templates/{id:int}", + "GET completion-templates/{id:int}/linked-work-orders", "GET lookups/dispatchers", "GET {id:int}", "GET {id:int}/audit", diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index 655892c..5631705 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -18,83 +18,111 @@ namespace Api.SeaHavenIndustries.Controllers public class WorkOrderCompletionController : Controller { private readonly IWorkOrderCompletionService _workOrderCompletionService; + private readonly ICompletionDocTemplateService _completionDocTemplateService; private readonly IFileStoragePort _fileStorage; public WorkOrderCompletionController( IWorkOrderCompletionService workOrderCompletionService, + ICompletionDocTemplateService completionDocTemplateService, IFileStoragePort fileStorage) { _workOrderCompletionService = workOrderCompletionService; + _completionDocTemplateService = completionDocTemplateService; _fileStorage = fileStorage; } [HttpGet("completion-templates")] public async Task GetCompletionTemplates( + [FromQuery] string? search = null, [FromQuery] string? serviceKey = null, - [FromQuery] WorkOrderType? workOrderType = null) + [FromQuery] WorkOrderType? workOrderType = null, + CancellationToken cancellationToken = default) { - var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType); + var templates = await _completionDocTemplateService.ListAsync(search, serviceKey, workOrderType, cancellationToken); return Ok(templates); } [HttpGet("completion-templates/{id:int}")] - public async Task GetCompletionTemplate(int id) + public async Task GetCompletionTemplate(int id, CancellationToken cancellationToken) { - var template = await _workOrderCompletionService.GetTemplateByIdAsync(id); + var template = await _completionDocTemplateService.GetAsync(id, cancellationToken); if (template == null) return NotFound(new Response { Status = "Error", Message = "Template not found." }); return Ok(template); } - [Authorize(Roles = "Admin")] - [HttpPost("completion-templates")] - public async Task CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request) + [HttpGet("completion-templates/{id:int}/linked-work-orders")] + public async Task GetCompletionTemplateLinkedWorkOrders(int id, CancellationToken cancellationToken) { try { - var created = await _workOrderCompletionService.CreateTemplateAsync(request); + var linked = await _completionDocTemplateService.GetLinkedWorkOrdersAsync(User, id, cancellationToken); + return Ok(linked); + } + catch (WorkOrderBoardValidationException ex) + { + return MapTemplateError(ex); + } + } + + [HttpPost("completion-templates")] + public async Task CreateCompletionTemplate( + [FromBody] CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken) + { + try + { + var created = await _completionDocTemplateService.CreateAsync(User, request, cancellationToken); return Ok(created); } catch (WorkOrderBoardValidationException ex) { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + return MapTemplateError(ex); } } - [Authorize(Roles = "Admin")] [HttpPut("completion-templates/{id:int}")] - public async Task UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request) + public async Task UpdateCompletionTemplate( + int id, + [FromBody] CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken) { try { - var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request); + var updated = await _completionDocTemplateService.UpdateAsync(User, id, request, cancellationToken); return Ok(updated); } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } catch (WorkOrderBoardValidationException ex) { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + return MapTemplateError(ex); } } - [Authorize(Roles = "Admin")] [HttpDelete("completion-templates/{id:int}")] - public async Task DeleteCompletionTemplate(int id) + public async Task DeleteCompletionTemplate(int id, CancellationToken cancellationToken) { try { - await _workOrderCompletionService.DeleteTemplateAsync(id); + await _completionDocTemplateService.DeleteAsync(User, id, cancellationToken); return NoContent(); } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + catch (WorkOrderBoardValidationException ex) { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + return MapTemplateError(ex); } } + private IActionResult MapTemplateError(WorkOrderBoardValidationException ex) + { + var body = new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }; + return ex.Code switch + { + "Forbidden" => StatusCode(StatusCodes.Status403Forbidden, body), + "NotFound" => NotFound(body), + _ => UnprocessableEntity(body) + }; + } + [HttpPost("{id:int}/completion-doc")] [RequestSizeLimit(50_000_000)] public async Task UploadCompletionDoc( diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index fe944ee..eec5ec0 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -54,6 +54,23 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(t => new { t.ServiceKey, t.IsActive }); + builder.Entity() + .Property(t => t.ExtraSafetyNote) + .HasMaxLength(CompletionDocTemplate.ExtraSafetyNoteMaxLength); + + builder.Entity(entity => + { + entity.Property(p => p.Name) + .HasMaxLength(CompletionDocTemplateProcedure.NameMaxLength); + + entity.HasOne(p => p.CompletionDocTemplate) + .WithMany(t => t.Procedures) + .HasForeignKey(p => p.CompletionDocTemplateId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(p => new { p.CompletionDocTemplateId, p.SortOrder }); + }); + builder.Entity() .Property(w => w.RowVersion) @@ -344,6 +361,7 @@ namespace Data.SeaHavenIndustries public DbSet workOrderCategories { get; set; } public DbSet workOrderAttachments { get; set; } public DbSet CompletionDocTemplates { get; set; } + public DbSet CompletionDocTemplateProcedures { get; set; } public DbSet WorkOrderAuditLogs { get; set; } public DbSet WorkOrderFieldLocks { get; set; } public DbSet WorkOrderWeekRolledLedgers { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.Designer.cs new file mode 100644 index 0000000..9a77d38 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.Designer.cs @@ -0,0 +1,4263 @@ +// +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("20260925135234_CompletionDocTemplateProcedures")] + partial class CompletionDocTemplateProcedures + { + /// + 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.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ServiceId"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.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.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("ServiceAreas"); + + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.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/20260925135234_CompletionDocTemplateProcedures.cs b/Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.cs new file mode 100644 index 0000000..d82e1d1 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class CompletionDocTemplateProcedures : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExtraSafetyNote", + table: "CompletionDocTemplates", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.CreateTable( + name: "CompletionDocTemplateProcedures", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CompletionDocTemplateId = table.Column(type: "int", nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CompletionDocTemplateProcedures", x => x.Id); + table.ForeignKey( + name: "FK_CompletionDocTemplateProcedures_CompletionDocTemplates_CompletionDocTemplateId", + column: x => x.CompletionDocTemplateId, + principalTable: "CompletionDocTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CompletionDocTemplateProcedures_CompletionDocTemplateId_SortOrder", + table: "CompletionDocTemplateProcedures", + columns: new[] { "CompletionDocTemplateId", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CompletionDocTemplateProcedures"); + + migrationBuilder.DropColumn( + name: "ExtraSafetyNote", + table: "CompletionDocTemplates"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 4a3d3c9..795219d 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -523,6 +523,10 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("DeletionTime") .HasColumnType("datetime2"); + b.Property("ExtraSafetyNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + b.Property("IsActive") .HasColumnType("bit"); @@ -560,6 +564,36 @@ namespace Data.SeaHavenIndustries.Migrations 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") @@ -3481,6 +3515,17 @@ namespace Data.SeaHavenIndustries.Migrations 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") @@ -4108,6 +4153,11 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("WorkOrderCategories"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Navigation("Procedures"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => { b.Navigation("WorkOrderContacts"); diff --git a/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs index 17cb4d3..c0bb517 100644 --- a/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs +++ b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs @@ -4,10 +4,32 @@ namespace Data.SeaHavenIndustries { public class CompletionDocTemplate : FullAuditEntity { + public const int ExtraSafetyNoteMaxLength = 2000; + public string Name { get; set; } = ""; public string ServiceKey { get; set; } = ""; public WorkOrderType? WorkOrderType { get; set; } public string TemplateUrl { get; set; } = ""; public bool IsActive { get; set; } = true; + + // At most one template-specific note, printed after the standard + // safety bullets every generated document carries. + public string? ExtraSafetyNote { get; set; } + + public virtual ICollection Procedures { get; set; } = new List(); + } + + // Ordered procedure step of a completion document template. SortOrder is the + // position the author gave it; documents print procedures in that order. + public class CompletionDocTemplateProcedure + { + public const int NameMaxLength = 200; + + public int Id { get; set; } + public int CompletionDocTemplateId { get; set; } + public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; } + public int SortOrder { get; set; } + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; } } diff --git a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs index 356b623..0b46c61 100644 --- a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs +++ b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -14,33 +15,6 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public async Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType) - { - var query = _context.CompletionDocTemplates - .AsNoTracking() - .Where(t => t.IsActive && t.IsDeleted != true); - - if (!string.IsNullOrWhiteSpace(serviceKey)) - query = query.Where(t => t.ServiceKey == serviceKey); - - if (workOrderType.HasValue) - query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType); - - return await query - .OrderBy(t => t.Name) - .Select(MapRow) - .ToListAsync(); - } - - public async Task GetByIdAsync(int id) - { - return await _context.CompletionDocTemplates - .AsNoTracking() - .Where(t => t.Id == id && t.IsDeleted != true) - .Select(MapRow) - .FirstOrDefaultAsync(); - } - public async Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType) { if (!string.IsNullOrWhiteSpace(trade)) @@ -70,33 +44,146 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(); } - public async Task CreateAsync(CompletionDocTemplate template) + public async Task> ListAsync( + string? search, + string? serviceKey, + WorkOrderType? workOrderType, + CancellationToken cancellationToken) + { + var query = _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true); + + if (!string.IsNullOrWhiteSpace(serviceKey)) + query = query.Where(t => t.ServiceKey == serviceKey); + + if (workOrderType.HasValue) + query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim().ToLower(); + query = query.Where(t => t.Name.ToLower().Contains(term)); + } + + return await ProjectDetail(query).ToListAsync(cancellationToken); + } + + public async Task GetDetailAsync(int id, CancellationToken cancellationToken) + { + var query = _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.Id == id && t.IsDeleted != true); + + return await ProjectDetail(query).FirstOrDefaultAsync(cancellationToken); + } + + public Task GetTrackedForEditAsync(int id, CancellationToken cancellationToken) + => _context.CompletionDocTemplates + .Include(t => t.Procedures) + .FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken); + + public async Task CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken) { template.CreatedDate = DateTime.UtcNow; - await _context.CompletionDocTemplates.AddAsync(template); - await _context.SaveChangesAsync(); - return template; + await _context.CompletionDocTemplates.AddAsync(template, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return template.Id; } - public async Task UpdateAsync(CompletionDocTemplate template) + public async Task SaveEditAsync( + CompletionDocTemplate template, + IReadOnlyList? replacementProcedures, + CancellationToken cancellationToken) { + if (replacementProcedures != null) + { + // Explicit removal: the context forces Restrict on every FK before the + // procedure cascade is configured, so orphan deletion is not relied on. + _context.CompletionDocTemplateProcedures.RemoveRange(template.Procedures.ToList()); + foreach (var procedure in replacementProcedures) + { + procedure.CompletionDocTemplateId = template.Id; + _context.CompletionDocTemplateProcedures.Add(procedure); + } + } + template.LastModificationTime = DateTime.UtcNow; - _context.CompletionDocTemplates.Update(template); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync(cancellationToken); } - public async Task DeleteAsync(int id) + public async Task DeleteAndUnlinkServicesAsync( + int id, + string? deleterUserId, + CancellationToken cancellationToken) { - var entity = await _context.CompletionDocTemplates.FindAsync(id); - if (entity == null) + var template = await _context.CompletionDocTemplates + .FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken); + if (template == null) return false; - entity.IsDeleted = true; - entity.DeletionTime = DateTime.UtcNow; - await _context.SaveChangesAsync(); + var now = DateTime.UtcNow; + template.IsDeleted = true; + template.DeletionTime = now; + template.DeleterUserId = deleterUserId; + + var linkedServices = await _context.Services + .Where(s => s.CompletionDocTemplateId == id) + .ToListAsync(cancellationToken); + foreach (var service in linkedServices) + { + service.CompletionDocTemplateId = null; + service.LastModificationTime = now; + } + + await _context.SaveChangesAsync(cancellationToken); return true; } + public async Task> GetOpenLinkedWorkOrderIdsAsync( + int templateId, + int? accountId, + CancellationToken cancellationToken) + { + var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId.HasValue) + workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value); + + var linkedServiceIds = _context.Services + .Where(s => s.CompletionDocTemplateId == templateId) + .Select(s => (int?)s.Id); + + return await workOrders + .Where(w => w.ServiceId != null && linkedServiceIds.Contains(w.ServiceId)) + .Where(w => !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)) + .OrderBy(w => w.Id) + .Select(w => w.Id) + .ToListAsync(cancellationToken); + } + + private IQueryable ProjectDetail(IQueryable templates) + => from t in templates + join u in _context.Users on t.createdby equals u.Id into creators + from creator in creators.DefaultIfEmpty() + orderby t.Name, t.Id + select new CompletionDocTemplateDetailRow( + t.Id, + t.Name, + t.ServiceKey, + t.WorkOrderType, + t.TemplateUrl, + t.IsActive, + t.ExtraSafetyNote, + t.CreatedDate, + t.LastModificationTime, + creator != null ? creator.FirstName : null, + creator != null ? creator.LastName : null, + t.Procedures + .OrderBy(p => p.SortOrder) + .ThenBy(p => p.Id) + .Select(p => new CompletionDocTemplateProcedureRow(p.Id, p.SortOrder, p.Name, p.Description)) + .ToList()); + private static readonly System.Linq.Expressions.Expression> MapRow = t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs index 0dae9d5..269745b 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs @@ -41,9 +41,6 @@ namespace SeaHaven.DataServices.Implementation public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; - public Task GetTrackedTemplateAsync(int id, CancellationToken cancellationToken) - => _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken); - public Task SaveAsync(CancellationToken cancellationToken) => _context.SaveChangesAsync(cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/CompletionDocTemplateModels.cs b/SeaHaven.DataServices/Interfaces/CompletionDocTemplateModels.cs new file mode 100644 index 0000000..688c3ea --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/CompletionDocTemplateModels.cs @@ -0,0 +1,26 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record CompletionDocTemplateProcedureRow( + int Id, + int SortOrder, + string Name, + string Description); + + // Read model for the completion document templates registry: the legacy + // columns plus authored content and audit display data. + public record CompletionDocTemplateDetailRow( + int Id, + string Name, + string ServiceKey, + WorkOrderType? WorkOrderType, + string TemplateUrl, + bool IsActive, + string? ExtraSafetyNote, + DateTime? CreatedAt, + DateTime? UpdatedAt, + string? CreatedByFirstName, + string? CreatedByLastName, + IReadOnlyList Procedures); +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs index 3116652..7b48297 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs @@ -15,7 +15,6 @@ namespace SeaHaven.DataServices.Interfaces CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); - Task GetTrackedTemplateAsync(int id, CancellationToken cancellationToken); Task SaveAsync(CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs index c934028..78104ca 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs @@ -25,11 +25,43 @@ namespace SeaHaven.DataServices.Interfaces public interface ICompletionDocTemplateDataService { - Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType); - Task GetByIdAsync(int id); Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType); - Task CreateAsync(CompletionDocTemplate template); - Task UpdateAsync(CompletionDocTemplate template); - Task DeleteAsync(int id); + + Task> ListAsync( + string? search, + string? serviceKey, + WorkOrderType? workOrderType, + CancellationToken cancellationToken); + + Task GetDetailAsync(int id, CancellationToken cancellationToken); + + /// Tracked, not-deleted template with its procedures loaded, for editing. + Task GetTrackedForEditAsync(int id, CancellationToken cancellationToken); + + Task CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken); + + /// + /// Commits edits to a template from . A non-null + /// replaces the whole procedure list. + /// + Task SaveEditAsync( + CompletionDocTemplate template, + IReadOnlyList? replacementProcedures, + CancellationToken cancellationToken); + + /// + /// Soft-deletes the template and clears every Service link to it in the same commit. + /// Services keep RequiresCompletionDocument. False when the template does not exist. + /// + Task DeleteAndUnlinkServicesAsync(int id, string? deleterUserId, CancellationToken cancellationToken); + + /// + /// Ids of open (non-terminal) work orders whose Service links this template, + /// restricted to when set. + /// + Task> GetOpenLinkedWorkOrderIdsAsync( + int templateId, + int? accountId, + CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index c16da2e..3c72403 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -30,13 +30,48 @@ namespace SeaHaven.Services.DTOs public bool IsActive { get; set; } } + // Completion document templates registry item and detail (same shape). + public class CompletionDocTemplateDetailDto : CompletionDocTemplateDto + { + public string? ExtraSafetyNote { get; set; } + public List Procedures { get; set; } = new(); + public DateTime? CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public string? CreatedByName { get; set; } + } + + public class CompletionDocTemplateProcedureDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public int SortOrder { get; set; } + } + + // Create/update body. Legacy fields are optional: on update, null leaves the + // stored value unchanged, as does a null Procedures list. ExtraSafetyNote is + // always applied, so null removes the note. public class CompletionDocTemplateCreateDto { - public string Name { get; set; } = ""; - public string ServiceKey { get; set; } = ""; + public string? Name { get; set; } + public string? ExtraSafetyNote { get; set; } + public List? Procedures { get; set; } + public string? ServiceKey { get; set; } public WorkOrderType? WorkOrderType { get; set; } - public string TemplateUrl { get; set; } = ""; - public bool IsActive { get; set; } = true; + public string? TemplateUrl { get; set; } + public bool? IsActive { get; set; } + } + + public class CompletionDocTemplateProcedureInputDto + { + public string? Name { get; set; } + public string? Description { get; set; } + } + + public class CompletionDocTemplateLinkedWorkOrdersDto + { + public int Count { get; set; } + public List WorkOrderIds { get; set; } = new(); } public class WorkOrderCompletionDto diff --git a/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs b/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs new file mode 100644 index 0000000..6f95833 --- /dev/null +++ b/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs @@ -0,0 +1,259 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public sealed class CompletionDocTemplateService : ICompletionDocTemplateService + { + private readonly ICompletionDocTemplateDataService _templateData; + private readonly ITeamPermissionOverrideDataService _permissionUsers; + private readonly ITeamPermissionPolicy _permissionPolicy; + private readonly IWorkOrderAccountResolver _accountResolver; + + public CompletionDocTemplateService( + ICompletionDocTemplateDataService templateData, + ITeamPermissionOverrideDataService permissionUsers, + ITeamPermissionPolicy permissionPolicy, + IWorkOrderAccountResolver accountResolver) + { + _templateData = templateData; + _permissionUsers = permissionUsers; + _permissionPolicy = permissionPolicy; + _accountResolver = accountResolver; + } + + public async Task> ListAsync( + string? search, + string? serviceKey, + WorkOrderType? workOrderType, + CancellationToken cancellationToken) + { + var rows = await _templateData.ListAsync(search, serviceKey, workOrderType, cancellationToken); + return rows.Select(Map).ToList(); + } + + public async Task GetAsync(int id, CancellationToken cancellationToken) + { + var row = await _templateData.GetDetailAsync(id, cancellationToken); + return row == null ? null : Map(row); + } + + public async Task GetLinkedWorkOrdersAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + + if (await _templateData.GetDetailAsync(id, cancellationToken) == null) + throw NotFound(); + + var ids = await _templateData.GetOpenLinkedWorkOrderIdsAsync(id, accountId, cancellationToken); + return new CompletionDocTemplateLinkedWorkOrdersDto { Count = ids.Count, WorkOrderIds = ids.ToList() }; + } + + public async Task CreateAsync( + ClaimsPrincipal user, + CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken) + { + var actorId = await EnsureAllowedAsync( + user, + TeamPermissionKeys.CreateCompletionDocTemplates, + "You do not have permission to create completion document templates.", + cancellationToken); + + var input = Validate(request); + var entity = new CompletionDocTemplate + { + Name = input.Name, + ExtraSafetyNote = input.ExtraSafetyNote, + ServiceKey = input.ServiceKey ?? "", + WorkOrderType = request.WorkOrderType, + TemplateUrl = input.TemplateUrl ?? "", + IsActive = request.IsActive ?? true, + createdby = actorId, + Procedures = (input.Procedures ?? new List()).ToList() + }; + + var id = await _templateData.CreateAsync(entity, cancellationToken); + return await GetSavedAsync(id, cancellationToken); + } + + public async Task UpdateAsync( + ClaimsPrincipal user, + int id, + CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken) + { + await EnsureAllowedAsync( + user, + TeamPermissionKeys.EditCompletionDocTemplates, + "You do not have permission to edit completion document templates.", + cancellationToken); + + var input = Validate(request); + var entity = await _templateData.GetTrackedForEditAsync(id, cancellationToken) ?? throw NotFound(); + + entity.Name = input.Name; + entity.ExtraSafetyNote = input.ExtraSafetyNote; + if (input.ServiceKey != null) + entity.ServiceKey = input.ServiceKey; + if (request.WorkOrderType.HasValue) + entity.WorkOrderType = request.WorkOrderType; + if (input.TemplateUrl != null) + entity.TemplateUrl = input.TemplateUrl; + if (request.IsActive.HasValue) + entity.IsActive = request.IsActive.Value; + + await _templateData.SaveEditAsync(entity, input.Procedures, cancellationToken); + return await GetSavedAsync(id, cancellationToken); + } + + public async Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken) + { + var actorId = await EnsureAllowedAsync( + user, + TeamPermissionKeys.DeleteCompletionDocTemplates, + "You do not have permission to delete completion document templates.", + cancellationToken); + + if (!await _templateData.DeleteAndUnlinkServicesAsync(id, actorId, cancellationToken)) + throw NotFound(); + } + + private async Task EnsureAllowedAsync( + ClaimsPrincipal user, + string permissionKey, + string deniedMessage, + CancellationToken cancellationToken) + { + var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier); + var permissionUser = string.IsNullOrWhiteSpace(userId) + ? null + : await _permissionUsers.GetUserAsync(userId, cancellationToken); + + if (permissionUser is null + || !_permissionPolicy.IsAllowed(permissionUser.RoleName, permissionKey, permissionUser.Overrides)) + { + throw new WorkOrderBoardValidationException("Forbidden", deniedMessage); + } + + return userId!; + } + + private async Task GetSavedAsync(int id, CancellationToken cancellationToken) + { + var row = await _templateData.GetDetailAsync(id, cancellationToken) ?? throw NotFound(); + return Map(row); + } + + private sealed record ValidatedInput( + string Name, + string? ExtraSafetyNote, + string? ServiceKey, + string? TemplateUrl, + IReadOnlyList? Procedures); + + private static ValidatedInput Validate(CompletionDocTemplateCreateDto request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Name)) + throw new WorkOrderBoardValidationException("TitleRequired", "Document Title is required."); + + var extraSafetyNote = string.IsNullOrWhiteSpace(request.ExtraSafetyNote) + ? null + : request.ExtraSafetyNote.Trim(); + if (extraSafetyNote?.Length > CompletionDocTemplate.ExtraSafetyNoteMaxLength) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + $"Extra safety note must be {CompletionDocTemplate.ExtraSafetyNoteMaxLength} characters or fewer."); + } + + var templateUrl = request.TemplateUrl?.Trim(); + if (!string.IsNullOrEmpty(templateUrl) + && (!Uri.TryCreate(templateUrl, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))) + { + throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL."); + } + + // Blank procedure names and descriptions are allowed: authors may keep a + // placeholder step and fill it in later. + List? procedures = null; + if (request.Procedures != null) + { + procedures = new List(request.Procedures.Count); + foreach (var procedure in request.Procedures) + { + var name = procedure?.Name?.Trim() ?? ""; + if (name.Length > CompletionDocTemplateProcedure.NameMaxLength) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + $"Procedure name must be {CompletionDocTemplateProcedure.NameMaxLength} characters or fewer."); + } + + procedures.Add(new CompletionDocTemplateProcedure + { + SortOrder = procedures.Count, + Name = name, + Description = procedure?.Description ?? "" + }); + } + } + + return new ValidatedInput( + request.Name.Trim(), + extraSafetyNote, + request.ServiceKey?.Trim(), + templateUrl, + procedures); + } + + private static WorkOrderBoardValidationException NotFound() + => new("NotFound", "Template not found."); + + private static CompletionDocTemplateDetailDto Map(CompletionDocTemplateDetailRow row) + { + var createdByName = string.Join( + " ", + new[] { row.CreatedByFirstName, row.CreatedByLastName } + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(part => part!.Trim())); + + return new CompletionDocTemplateDetailDto + { + Id = row.Id, + Name = row.Name, + ServiceKey = row.ServiceKey, + WorkOrderType = row.WorkOrderType, + TemplateUrl = row.TemplateUrl, + IsActive = row.IsActive, + ExtraSafetyNote = row.ExtraSafetyNote, + Procedures = row.Procedures + .Select(p => new CompletionDocTemplateProcedureDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + SortOrder = p.SortOrder + }) + .ToList(), + CreatedAt = AsUtc(row.CreatedAt), + UpdatedAt = AsUtc(row.UpdatedAt), + CreatedByName = createdByName.Length == 0 ? null : createdByName + }; + } + + // Audit timestamps are written as DateTime.UtcNow; the store drops the kind. + private static DateTime? AsUtc(DateTime? value) + => value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null; + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs index 534cd6b..1ed1876 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -31,18 +31,6 @@ namespace SeaHaven.Services.Implementation _accountResolver = accountResolver; } - public async Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) - { - var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType); - return rows.Select(WorkOrderDetailService.MapTemplate).ToList(); - } - - public async Task GetTemplateByIdAsync(int id) - { - var row = await _templateData.GetByIdAsync(id); - return row == null ? null : WorkOrderDetailService.MapTemplate(row); - } - public async Task EnsureCanUploadCompletionDocAsync( int workOrderId, ClaimsPrincipal user, @@ -125,49 +113,6 @@ namespace SeaHaven.Services.Implementation }; } - public async Task CreateTemplateAsync(CompletionDocTemplateCreateDto request) - { - ValidateTemplateRequest(request); - - var entity = new CompletionDocTemplate - { - Name = request.Name.Trim(), - ServiceKey = request.ServiceKey.Trim(), - WorkOrderType = request.WorkOrderType, - TemplateUrl = request.TemplateUrl.Trim(), - IsActive = request.IsActive - }; - - var created = await _templateData.CreateAsync(entity); - return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( - created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive)); - } - - public async Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request) - { - ValidateTemplateRequest(request); - - var entity = await _completionData.GetTrackedTemplateAsync(id, CancellationToken.None); - if (entity == null) - throw new WorkOrderBoardValidationException("NotFound", "Template not found."); - - entity.Name = request.Name.Trim(); - entity.ServiceKey = request.ServiceKey.Trim(); - entity.WorkOrderType = request.WorkOrderType; - entity.TemplateUrl = request.TemplateUrl.Trim(); - entity.IsActive = request.IsActive; - - await _templateData.UpdateAsync(entity); - return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( - entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive)); - } - - public async Task DeleteTemplateAsync(int id) - { - if (!await _templateData.DeleteAsync(id)) - throw new WorkOrderBoardValidationException("NotFound", "Template not found."); - } - private async Task GetMutableWorkOrderForAuthAsync( int workOrderId, ClaimsPrincipal user, @@ -185,21 +130,6 @@ namespace SeaHaven.Services.Implementation return workOrder; } - private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request) - { - if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey)) - throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required."); - - if (string.IsNullOrWhiteSpace(request.TemplateUrl)) - throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required."); - - if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri) - || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) - { - throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL."); - } - } - private static byte[]? ParseRowVersion(string? base64) { if (string.IsNullOrWhiteSpace(base64)) diff --git a/SeaHaven.Services/Interfaces/ICompletionDocTemplateService.cs b/SeaHaven.Services/Interfaces/ICompletionDocTemplateService.cs new file mode 100644 index 0000000..c89f47a --- /dev/null +++ b/SeaHaven.Services/Interfaces/ICompletionDocTemplateService.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + /// + /// Completion document templates registry: authoring (title, extra safety note, + /// ordered procedures), search, and safe deletion. Reads are open to every + /// authenticated caller; writes require the matching team permission. + /// + public interface ICompletionDocTemplateService + { + Task> ListAsync( + string? search, + string? serviceKey, + WorkOrderType? workOrderType, + CancellationToken cancellationToken); + + Task GetAsync(int id, CancellationToken cancellationToken); + + Task GetLinkedWorkOrdersAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken); + + Task CreateAsync( + ClaimsPrincipal user, + CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken); + + Task UpdateAsync( + ClaimsPrincipal user, + int id, + CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken); + + Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs index 12c2399..eba7d90 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs @@ -1,13 +1,10 @@ using System.Security.Claims; -using Data.SeaHavenIndustries.Enums; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IWorkOrderCompletionService { - Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); - Task GetTemplateByIdAsync(int id); Task EnsureCanUploadCompletionDocAsync( int workOrderId, ClaimsPrincipal user, @@ -20,8 +17,5 @@ namespace SeaHaven.Services.Interfaces ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default); - Task CreateTemplateAsync(CompletionDocTemplateCreateDto request); - Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request); - Task DeleteTemplateAsync(int id); } } diff --git a/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs b/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs new file mode 100644 index 0000000..ccb49ee --- /dev/null +++ b/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs @@ -0,0 +1,641 @@ +using System.Reflection; +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Data.SeaHavenIndustries.Migrations; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +// Completion document templates registry: authoring with ordered procedures, +// search, permission-gated writes, tenant-scoped linked work orders, and a +// delete that unlinks Services without dropping their document requirement. +public class CompletionDocTemplateServiceTests +{ + private const string AdminId = "admin-1"; + private const string SchedulerId = "scheduler-1"; + private const string DispatcherId = "dispatcher-1"; + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + SeedUser(context, AdminId, "Admin", "Jane", "Doe"); + SeedUser(context, SchedulerId, "Scheduler", "Sam", "Scheduler"); + SeedUser(context, DispatcherId, "Dispatcher", "Dee", "Dispatcher"); + context.SaveChanges(); + return context; + } + + private static void SeedUser(ApplicationDbContext context, string userId, string role, string first, string last) + { + context.Users.Add(new ApplicationUser + { + Id = userId, + UserName = userId, + NormalizedUserName = userId.ToUpperInvariant(), + FirstName = first, + LastName = last + }); + var roleId = "role-" + role; + if (context.Roles.Local.All(r => r.Id != roleId)) + context.Roles.Add(new IdentityRole { Id = roleId, Name = role, NormalizedName = role.ToUpperInvariant() }); + context.UserRoles.Add(new IdentityUserRole { UserId = userId, RoleId = roleId }); + } + + private static CompletionDocTemplateService NewService(ApplicationDbContext context) => + new( + new CompletionDocTemplateDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), + WorkOrderAccountTestHelpers.Resolver(context)); + + private static ClaimsPrincipal Caller(string userId, string role, int? accountId = null) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.Role, role) + }; + claims.Add(accountId.HasValue + ? new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString()) + : new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)); + return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test")); + } + + private static ClaimsPrincipal Admin(int? accountId = null) => Caller(AdminId, "Admin", accountId); + private static ClaimsPrincipal Scheduler() => Caller(SchedulerId, "Scheduler"); + private static ClaimsPrincipal Dispatcher() => Caller(DispatcherId, "Dispatcher"); + + private static CompletionDocTemplateCreateDto Body( + string? name = "Backflow Test Report", + string? extraSafetyNote = null, + params (string? Name, string? Description)[] procedures) => new() + { + Name = name, + ExtraSafetyNote = extraSafetyNote, + Procedures = procedures + .Select(p => new CompletionDocTemplateProcedureInputDto { Name = p.Name, Description = p.Description }) + .ToList() + }; + + [Fact] + public async Task Create_PersistsTitleNoteAndProceduresInSubmittedOrder() + { + using var context = NewContext(); + var service = NewService(context); + + var created = await service.CreateAsync( + Admin(), + Body(" Backflow Test Report ", " Wear gloves ", + ("Backflow Procedure", "line 1\nline 2"), + ("Shutoff", "Close valve"), + ("Cleanup", "")), + CancellationToken.None); + + Assert.Equal("Backflow Test Report", created.Name); + Assert.Equal("Wear gloves", created.ExtraSafetyNote); + Assert.Equal(new[] { "Backflow Procedure", "Shutoff", "Cleanup" }, created.Procedures.Select(p => p.Name)); + Assert.Equal(new[] { 0, 1, 2 }, created.Procedures.Select(p => p.SortOrder)); + Assert.Equal("line 1\nline 2", created.Procedures[0].Description); + Assert.All(created.Procedures, p => Assert.True(p.Id > 0)); + Assert.Equal("Jane Doe", created.CreatedByName); + Assert.NotNull(created.CreatedAt); + Assert.Equal(DateTimeKind.Utc, created.CreatedAt!.Value.Kind); + Assert.Null(created.UpdatedAt); + Assert.True(created.IsActive); + Assert.Equal("", created.ServiceKey); + Assert.Equal("", created.TemplateUrl); + + var fetched = await service.GetAsync(created.Id, CancellationToken.None); + Assert.Equal(created.Procedures.Select(p => p.Id), fetched!.Procedures.Select(p => p.Id)); + } + + [Fact] + public async Task Create_WithZeroProceduresAndNoNote_Succeeds() + { + using var context = NewContext(); + + var created = await NewService(context).CreateAsync( + Admin(), new CompletionDocTemplateCreateDto { Name = "Empty" }, CancellationToken.None); + + Assert.Empty(created.Procedures); + Assert.Null(created.ExtraSafetyNote); + } + + [Fact] + public async Task Create_AllowsBlankProcedureNameAndDescription() + { + using var context = NewContext(); + + var created = await NewService(context).CreateAsync( + Admin(), Body("Placeholder", null, (null, null), (" ", "")), CancellationToken.None); + + Assert.Equal(2, created.Procedures.Count); + Assert.All(created.Procedures, p => Assert.Equal("", p.Name)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateAndUpdate_WithoutTitle_ThrowTitleRequired(string? name) + { + using var context = NewContext(); + var service = NewService(context); + var existing = await service.CreateAsync(Admin(), Body(), CancellationToken.None); + + var onCreate = await Assert.ThrowsAsync(() => + service.CreateAsync(Admin(), Body(name), CancellationToken.None)); + var onUpdate = await Assert.ThrowsAsync(() => + service.UpdateAsync(Admin(), existing.Id, Body(name), CancellationToken.None)); + + Assert.Equal("TitleRequired", onCreate.Code); + Assert.Equal("Document Title is required.", onCreate.Message); + Assert.Equal("TitleRequired", onUpdate.Code); + Assert.Single(context.CompletionDocTemplates); + } + + [Fact] + public async Task Create_RejectsOverlongExtraSafetyNoteAndProcedureName() + { + using var context = NewContext(); + var service = NewService(context); + + var note = await Assert.ThrowsAsync(() => + service.CreateAsync(Admin(), Body("T", new string('n', 2001)), CancellationToken.None)); + var procedure = await Assert.ThrowsAsync(() => + service.CreateAsync(Admin(), Body("T", null, (new string('p', 201), "d")), CancellationToken.None)); + + Assert.Equal("InvalidValue", note.Code); + Assert.Equal("InvalidValue", procedure.Code); + Assert.Empty(context.CompletionDocTemplates); + } + + [Fact] + public async Task Update_ReplacesWholeProcedureListAndStampsUpdatedAt() + { + using var context = NewContext(); + var service = NewService(context); + var created = await service.CreateAsync( + Admin(), Body("T", "Note", ("A", "a"), ("B", "b"), ("C", "c")), CancellationToken.None); + + var updated = await service.UpdateAsync( + Admin(), created.Id, Body("T2", "Note", ("C", "c2"), ("A", "a2")), CancellationToken.None); + + Assert.Equal("T2", updated.Name); + Assert.Equal(new[] { "C", "A" }, updated.Procedures.Select(p => p.Name)); + Assert.Equal(new[] { 0, 1 }, updated.Procedures.Select(p => p.SortOrder)); + Assert.Equal(2, context.CompletionDocTemplateProcedures.Count()); + Assert.NotNull(updated.UpdatedAt); + Assert.Equal("Jane Doe", updated.CreatedByName); + } + + [Fact] + public async Task Update_NullNoteClearsIt_EmptyProceduresClearThem_NullProceduresKeepThem() + { + using var context = NewContext(); + var service = NewService(context); + var created = await service.CreateAsync(Admin(), Body("T", "Note", ("A", "a")), CancellationToken.None); + + var kept = await service.UpdateAsync( + Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = null }, CancellationToken.None); + Assert.Null(kept.ExtraSafetyNote); + Assert.Equal(new[] { "A" }, kept.Procedures.Select(p => p.Name)); + + var cleared = await service.UpdateAsync( + Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = new() }, CancellationToken.None); + Assert.Empty(cleared.Procedures); + } + + [Fact] + public async Task Update_OmittedLegacyFieldsKeepStoredValues() + { + using var context = NewContext(); + context.CompletionDocTemplates.Add(new CompletionDocTemplate + { + Id = 7, + Name = "HVAC PM Completion", + ServiceKey = "HVAC PM", + WorkOrderType = WorkOrderType.PM, + TemplateUrl = "https://example.com/hvac.pdf", + IsActive = true + }); + await context.SaveChangesAsync(); + + var updated = await NewService(context).UpdateAsync( + Admin(), 7, Body("HVAC PM Completion v2", "Mask"), CancellationToken.None); + + Assert.Equal("HVAC PM", updated.ServiceKey); + Assert.Equal(WorkOrderType.PM, updated.WorkOrderType); + Assert.Equal("https://example.com/hvac.pdf", updated.TemplateUrl); + Assert.True(updated.IsActive); + Assert.Null(updated.CreatedByName); + } + + [Fact] + public async Task Update_MissingOrDeletedTemplate_ThrowsNotFound() + { + using var context = NewContext(); + context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 3, Name = "Gone", IsDeleted = true }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var missing = await Assert.ThrowsAsync(() => + service.UpdateAsync(Admin(), 999, Body(), CancellationToken.None)); + var deleted = await Assert.ThrowsAsync(() => + service.UpdateAsync(Admin(), 3, Body(), CancellationToken.None)); + + Assert.Equal("NotFound", missing.Code); + Assert.Equal("NotFound", deleted.Code); + } + + [Fact] + public async Task List_SearchIsPartialCaseInsensitiveAndOrderedByName() + { + using var context = NewContext(); + var service = NewService(context); + foreach (var name in new[] { "Roof Inspection", "Backflow Test Report", "Annual BACKFLOW check", "HVAC PM" }) + await service.CreateAsync(Admin(), Body(name), CancellationToken.None); + context.CompletionDocTemplates.Add(new CompletionDocTemplate { Name = "Backflow deleted", IsDeleted = true }); + await context.SaveChangesAsync(); + + var matches = await service.ListAsync("backFLOW", null, null, CancellationToken.None); + var all = await service.ListAsync(null, null, null, CancellationToken.None); + var none = await service.ListAsync("zzz", null, null, CancellationToken.None); + + Assert.Equal(new[] { "Annual BACKFLOW check", "Backflow Test Report" }, matches.Select(t => t.Name)); + Assert.Equal( + new[] { "Annual BACKFLOW check", "Backflow Test Report", "HVAC PM", "Roof Inspection" }, + all.Select(t => t.Name)); + Assert.Empty(none); + } + + [Fact] + public async Task List_KeepsLegacyServiceKeyAndWorkOrderTypeFilters() + { + using var context = NewContext(); + context.CompletionDocTemplates.AddRange( + new CompletionDocTemplate { Name = "HVAC PM", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.PM }, + new CompletionDocTemplate { Name = "HVAC Any", ServiceKey = "HVAC" }, + new CompletionDocTemplate { Name = "HVAC Reactive", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.Reactive }, + new CompletionDocTemplate { Name = "Plumbing", ServiceKey = "Plumbing" }); + await context.SaveChangesAsync(); + + var rows = await NewService(context).ListAsync(null, "HVAC", WorkOrderType.PM, CancellationToken.None); + + Assert.Equal(new[] { "HVAC Any", "HVAC PM" }, rows.Select(t => t.Name)); + } + + [Fact] + public async Task Writes_FollowTeamPermissions() + { + using var context = NewContext(); + var service = NewService(context); + + var byScheduler = await service.CreateAsync(Scheduler(), Body("Scheduler template"), CancellationToken.None); + await service.UpdateAsync(Scheduler(), byScheduler.Id, Body("Scheduler edit"), CancellationToken.None); + Assert.Equal("Sam Scheduler", byScheduler.CreatedByName); + + var schedulerDelete = await Assert.ThrowsAsync(() => + service.DeleteAsync(Scheduler(), byScheduler.Id, CancellationToken.None)); + var dispatcherCreate = await Assert.ThrowsAsync(() => + service.CreateAsync(Dispatcher(), Body("Nope"), CancellationToken.None)); + var dispatcherEdit = await Assert.ThrowsAsync(() => + service.UpdateAsync(Dispatcher(), byScheduler.Id, Body("Nope"), CancellationToken.None)); + var anonymous = await Assert.ThrowsAsync(() => + service.CreateAsync(new ClaimsPrincipal(new ClaimsIdentity()), Body("Nope"), CancellationToken.None)); + + Assert.All(new[] { schedulerDelete, dispatcherCreate, dispatcherEdit, anonymous }, + ex => Assert.Equal("Forbidden", ex.Code)); + Assert.Equal("Scheduler edit", context.CompletionDocTemplates.Single().Name); + Assert.NotEqual(true, context.CompletionDocTemplates.Single().IsDeleted); + } + + [Fact] + public async Task Writes_HonorPerPersonOverrides() + { + using var context = NewContext(); + context.UserPermissionOverrides.Add(new UserPermissionOverride + { + UserId = SchedulerId, + PermissionKey = "deleteCompletionDocTemplates", + State = UserPermissionState.Allow + }); + context.UserPermissionOverrides.Add(new UserPermissionOverride + { + UserId = SchedulerId, + PermissionKey = "createCompletionDocTemplates", + State = UserPermissionState.Deny + }); + await context.SaveChangesAsync(); + var service = NewService(context); + var template = await service.CreateAsync(Admin(), Body(), CancellationToken.None); + + var denied = await Assert.ThrowsAsync(() => + service.CreateAsync(Scheduler(), Body("Denied"), CancellationToken.None)); + await service.DeleteAsync(Scheduler(), template.Id, CancellationToken.None); + + Assert.Equal("Forbidden", denied.Code); + Assert.True(context.CompletionDocTemplates.Single().IsDeleted); + } + + [Fact] + public async Task LinkedWorkOrders_CountsOnlyOpenWorkOrdersOfLinkedServicesInCallerTenant() + { + using var context = NewContext(); + var template = new CompletionDocTemplate { Id = 10, Name = "Backflow" }; + var other = new CompletionDocTemplate { Id = 11, Name = "Other" }; + context.CompletionDocTemplates.AddRange(template, other); + context.Services.AddRange( + new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 }, + new Service { Id = 2, Name = "Backflow 2", NormalizedName = "BACKFLOW 2", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 }, + new Service { Id = 3, Name = "Roof", NormalizedName = "ROOF", RequiresCompletionDocument = true, CompletionDocTemplateId = 11 }); + context.workOrders.AddRange( + new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled }, + new WorkOrder { Id = 102, ServiceId = 2, AccountId = 1, LifecycleStatus = null }, + new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed }, + new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled }, + new WorkOrder { Id = 105, ServiceId = 3, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled }, + new WorkOrder { Id = 106, ServiceId = null, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled }, + new WorkOrder { Id = 107, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, IsDeleted = true }, + new WorkOrder { Id = 108, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, istemplate = true }, + new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.InProgress }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var tenantOne = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 1), 10, CancellationToken.None); + var tenantTwo = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 2), 10, CancellationToken.None); + var orgWide = await service.GetLinkedWorkOrdersAsync(Admin(), 10, CancellationToken.None); + + Assert.Equal(2, tenantOne.Count); + Assert.Equal(new[] { 101, 102 }, tenantOne.WorkOrderIds); + Assert.Equal(new[] { 205 }, tenantTwo.WorkOrderIds); + Assert.Equal(new[] { 101, 102, 205 }, orgWide.WorkOrderIds); + Assert.Equal(3, orgWide.Count); + } + + [Fact] + public async Task LinkedWorkOrders_UnknownTemplateOrMissingScope_Throws() + { + using var context = NewContext(); + context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" }); + await context.SaveChangesAsync(); + var service = NewService(context); + var noScope = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, DispatcherId), new Claim(ClaimTypes.Role, "Dispatcher") }, + authenticationType: "test")); + + var missing = await Assert.ThrowsAsync(() => + service.GetLinkedWorkOrdersAsync(Admin(), 999, CancellationToken.None)); + var forbidden = await Assert.ThrowsAsync(() => + service.GetLinkedWorkOrdersAsync(noScope, 10, CancellationToken.None)); + + Assert.Equal("NotFound", missing.Code); + Assert.Equal("Forbidden", forbidden.Code); + } + + [Fact] + public async Task Delete_ClearsServiceLinksKeepsDocumentRequirementAndHidesTemplate() + { + using var context = NewContext(); + context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" }); + context.Services.AddRange( + new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 }, + new Service { Id = 2, Name = "Unrelated", NormalizedName = "UNRELATED", RequiresCompletionDocument = true, CompletionDocTemplateId = null }); + await context.SaveChangesAsync(); + var service = NewService(context); + + await service.DeleteAsync(Admin(), 10, CancellationToken.None); + + var linked = context.Services.Single(s => s.Id == 1); + Assert.Null(linked.CompletionDocTemplateId); + Assert.True(linked.RequiresCompletionDocument); + Assert.True(context.Services.Single(s => s.Id == 2).RequiresCompletionDocument); + var template = context.CompletionDocTemplates.Single(); + Assert.True(template.IsDeleted); + Assert.Equal(AdminId, template.DeleterUserId); + Assert.Null(await service.GetAsync(10, CancellationToken.None)); + Assert.Empty(await service.ListAsync(null, null, null, CancellationToken.None)); + + var again = await Assert.ThrowsAsync(() => + service.DeleteAsync(Admin(), 10, CancellationToken.None)); + Assert.Equal("NotFound", again.Code); + } + + [Fact] + public async Task Reads_ForwardCancellation() + { + using var context = NewContext(); + var service = NewService(context); + await service.CreateAsync(Admin(), Body(), CancellationToken.None); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + service.ListAsync(null, null, null, cts.Token)); + await Assert.ThrowsAnyAsync(() => + service.CreateAsync(Admin(), Body("Cancelled"), cts.Token)); + Assert.Single(context.CompletionDocTemplates); + } + + // Relational evidence (SQLite enforces foreign keys; the in-memory provider does not). + + [Fact] + public async Task Relational_DeleteOfLinkedTemplateSucceedsAndUnlinksService() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = await NewSqliteContextAsync(connection); + SeedUser(context, AdminId, "Admin", "Jane", "Doe"); + var template = new CompletionDocTemplate { Name = "Backflow" }; + context.CompletionDocTemplates.Add(template); + await context.SaveChangesAsync(); + context.Services.Add(new Service + { + Name = "Backflow", + NormalizedName = "BACKFLOW", + RequiresCompletionDocument = true, + CompletionDocTemplateId = template.Id + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + await service.DeleteAsync(Admin(), template.Id, CancellationToken.None); + + context.ChangeTracker.Clear(); + var stored = await context.Services.SingleAsync(); + Assert.Null(stored.CompletionDocTemplateId); + Assert.True(stored.RequiresCompletionDocument); + Assert.True((await context.CompletionDocTemplates.SingleAsync()).IsDeleted); + } + + [Fact] + public async Task Relational_UpdateReplacesProceduresInOrder() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = await NewSqliteContextAsync(connection); + SeedUser(context, AdminId, "Admin", "Jane", "Doe"); + await context.SaveChangesAsync(); + var service = NewService(context); + var created = await service.CreateAsync( + Admin(), Body("T", null, ("A", "a"), ("B", "b")), CancellationToken.None); + + context.ChangeTracker.Clear(); + var updated = await service.UpdateAsync( + Admin(), created.Id, Body("T", null, ("B", "b"), ("C", "c"), ("A", "a")), CancellationToken.None); + + Assert.Equal(new[] { "B", "C", "A" }, updated.Procedures.Select(p => p.Name)); + Assert.Equal("Jane Doe", updated.CreatedByName); + context.ChangeTracker.Clear(); + Assert.Equal(3, await context.CompletionDocTemplateProcedures.CountAsync()); + } + + private static async Task NewSqliteContextAsync(SqliteConnection connection) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + var context = new SqliteTemplateTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + return context; + } + + private sealed class SqliteTemplateTestDbContext : ApplicationDbContext + { + public SqliteTemplateTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // SQL Server filtered index syntax is invalid on SQLite. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + // SQLite has no rowversion type; treat as plain nullable blobs. + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } + + // HTTP contract of the template endpoints. + + private static WorkOrderCompletionController NewController(ApplicationDbContext context, ClaimsPrincipal user) => + new(null!, NewService(context), null!) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = user } } + }; + + [Fact] + public async Task Controller_MapsTitleRequiredTo422AndForbiddenTo403() + { + using var context = NewContext(); + + var blank = await NewController(context, Admin()) + .CreateCompletionTemplate(Body(" "), CancellationToken.None); + var denied = await NewController(context, Dispatcher()) + .CreateCompletionTemplate(Body("Allowed title"), CancellationToken.None); + var missing = await NewController(context, Admin()) + .DeleteCompletionTemplate(999, CancellationToken.None); + + var unprocessable = Assert.IsType(blank); + var error = Assert.IsType(unprocessable.Value); + Assert.Equal("TitleRequired", error.Code); + Assert.Equal("Document Title is required.", error.Message); + var forbidden = Assert.IsType(denied); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.IsType(missing); + } + + [Fact] + public async Task Controller_LinkedWorkOrdersAndDelete_ReturnContractShapes() + { + using var context = NewContext(); + context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" }); + context.Services.Add(new Service { Id = 1, Name = "B", NormalizedName = "B", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 }); + context.workOrders.Add(new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled }); + await context.SaveChangesAsync(); + var controller = NewController(context, Admin()); + + var linked = await controller.GetCompletionTemplateLinkedWorkOrders(10, CancellationToken.None); + var deleted = await controller.DeleteCompletionTemplate(10, CancellationToken.None); + + var body = Assert.IsType(Assert.IsType(linked).Value); + Assert.Equal(1, body.Count); + Assert.Equal(new[] { 101 }, body.WorkOrderIds); + Assert.IsType(deleted); + } + + // Migration shape (G6 evidence): additive only. + + [Fact] + public void Migration_Up_AddsBoundedNoteAndCascadingProcedureTableOnly() + { + var operations = BuildOperations("Up"); + + var column = Assert.Single(operations.OfType()); + Assert.Equal("CompletionDocTemplates", column.Table); + Assert.Equal("ExtraSafetyNote", column.Name); + Assert.True(column.IsNullable); + Assert.Equal(2000, column.MaxLength); + + var table = Assert.Single(operations.OfType()); + Assert.Equal("CompletionDocTemplateProcedures", table.Name); + Assert.Contains(table.Columns, c => c.Name == "Name" && c.MaxLength == 200 && !c.IsNullable); + var fk = Assert.Single(table.ForeignKeys); + Assert.Equal("CompletionDocTemplates", fk.PrincipalTable); + Assert.Equal(ReferentialAction.Cascade, fk.OnDelete); + + var index = Assert.Single(operations.OfType()); + Assert.Equal(new[] { "CompletionDocTemplateId", "SortOrder" }, index.Columns); + + Assert.Equal(3, operations.Count); + } + + [Fact] + public void Migration_Down_DropsOnlyWhatUpAdded() + { + var operations = BuildOperations("Down"); + + Assert.Equal("CompletionDocTemplateProcedures", Assert.Single(operations.OfType()).Name); + var dropped = Assert.Single(operations.OfType()); + Assert.Equal(("CompletionDocTemplates", "ExtraSafetyNote"), (dropped.Table, dropped.Name)); + Assert.Equal(2, operations.Count); + } + + private static List BuildOperations(string direction) + { + var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); + var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Migration.{direction} not found."); + operation.Invoke(new CompletionDocTemplateProcedures(), [builder]); + return builder.Operations; + } +} From cd23ad5b688eda9ef07e4e6a8c6e4aacf7040c0b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:19:59 -0300 Subject: [PATCH 2/3] fix(completion-templates): read legacy status when counting open linked work orders Work orders without a LifecycleStatus are open or closed according to their legacy status text. The linked work-order count now goes through the shared board status filter, so a legacy completed or cancelled row is no longer reported as depending on the template. --- .../CompletionDocTemplateDataService.cs | 9 +++- .../CompletionDocTemplateServiceTests.cs | 51 +++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs index 0b46c61..0e7a9a7 100644 --- a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs +++ b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs @@ -8,6 +8,10 @@ namespace SeaHaven.DataServices.Implementation { public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService { + private static readonly LifecycleStatus[] OpenStatuses = Enum.GetValues() + .Where(status => !LifecycleStatusSets.Terminal.Contains(status)) + .ToArray(); + private readonly ApplicationDbContext _context; public CompletionDocTemplateDataService(ApplicationDbContext context) @@ -153,9 +157,12 @@ namespace SeaHaven.DataServices.Implementation .Where(s => s.CompletionDocTemplateId == templateId) .Select(s => (int?)s.Id); + // Shared status filter so legacy rows without a LifecycleStatus are read + // through their legacy status text (a legacy "completed" row is not open). + workOrders = WorkOrderBoardQueryFilters.ApplyStatusFilter(workOrders, OpenStatuses); + return await workOrders .Where(w => w.ServiceId != null && linkedServiceIds.Contains(w.ServiceId)) - .Where(w => !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)) .OrderBy(w => w.Id) .Select(w => w.Id) .ToListAsync(cancellationToken); diff --git a/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs b/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs index ccb49ee..cc53bbe 100644 --- a/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs +++ b/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs @@ -376,6 +376,7 @@ public class CompletionDocTemplateServiceTests new WorkOrder { Id = 107, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, IsDeleted = true }, new WorkOrder { Id = 108, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, istemplate = true }, new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.InProgress }); + context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1)); await context.SaveChangesAsync(); var service = NewService(context); @@ -383,13 +384,21 @@ public class CompletionDocTemplateServiceTests var tenantTwo = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 2), 10, CancellationToken.None); var orgWide = await service.GetLinkedWorkOrdersAsync(Admin(), 10, CancellationToken.None); - Assert.Equal(2, tenantOne.Count); - Assert.Equal(new[] { 101, 102 }, tenantOne.WorkOrderIds); + Assert.Equal(3, tenantOne.Count); + Assert.Equal(new[] { 101, 102, 111 }, tenantOne.WorkOrderIds); Assert.Equal(new[] { 205 }, tenantTwo.WorkOrderIds); - Assert.Equal(new[] { 101, 102, 205 }, orgWide.WorkOrderIds); - Assert.Equal(3, orgWide.Count); + Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide.WorkOrderIds); + Assert.Equal(4, orgWide.Count); } + // Legacy rows carry no LifecycleStatus; their legacy status text decides whether they are open. + private static WorkOrder[] LegacyStatusWorkOrders(int serviceId, int accountId) => new[] + { + new WorkOrder { Id = 109, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "Completed" }, + new WorkOrder { Id = 110, ServiceId = serviceId, AccountId = accountId, Status = " Cancelled " }, + new WorkOrder { Id = 111, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "On Hold" } + }; + [Fact] public async Task LinkedWorkOrders_UnknownTemplateOrMissingScope_Throws() { @@ -485,6 +494,40 @@ public class CompletionDocTemplateServiceTests Assert.True((await context.CompletionDocTemplates.SingleAsync()).IsDeleted); } + [Fact] + public async Task Relational_LinkedWorkOrdersExcludeTerminalAndLegacyTerminalRows() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = await NewSqliteContextAsync(connection); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "Other Corp"); + context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" }); + context.Services.Add(new Service + { + Id = 1, + Name = "Backflow", + NormalizedName = "BACKFLOW", + RequiresCompletionDocument = true, + CompletionDocTemplateId = 10 + }); + context.workOrders.AddRange( + new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled }, + new WorkOrder { Id = 102, ServiceId = 1, AccountId = 1 }, + new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed }, + new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled }, + new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.OnSite }); + context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1)); + await context.SaveChangesAsync(); + var data = new CompletionDocTemplateDataService(context); + + var tenantOne = await data.GetOpenLinkedWorkOrderIdsAsync(10, 1, CancellationToken.None); + var orgWide = await data.GetOpenLinkedWorkOrderIdsAsync(10, null, CancellationToken.None); + + Assert.Equal(new[] { 101, 102, 111 }, tenantOne); + Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide); + } + [Fact] public async Task Relational_UpdateReplacesProceduresInOrder() { From a224f883bc47b5ffcbd372fe1fb1bc9f3a08b173 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:40:49 -0300 Subject: [PATCH 3/3] fix(completion-templates): let PUT clear workOrderType with an explicit null UpdateAsync only applied WorkOrderType when it had a value, so once a template was restricted to one work order type no request could make it trade-generic again. The DTO now records whether workOrderType was present in the body: omitting it keeps the stored value, an explicit null clears it, and a concrete value sets it. The templates page echoes the stored legacy fields on PUT, so its behaviour is unchanged. --- SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 24 +++++++++++-- .../CompletionDocTemplateService.cs | 2 +- .../CompletionDocTemplateServiceTests.cs | 36 +++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 3c72403..0ae5ed7 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -49,15 +49,33 @@ namespace SeaHaven.Services.DTOs } // Create/update body. Legacy fields are optional: on update, null leaves the - // stored value unchanged, as does a null Procedures list. ExtraSafetyNote is - // always applied, so null removes the note. + // stored value unchanged, as does a null Procedures list. WorkOrderType is the + // exception: omitting it keeps the stored value, while an explicit + // "workOrderType": null clears it so the template applies to every work order + // type of its trade. ExtraSafetyNote is always applied, so null removes the note. public class CompletionDocTemplateCreateDto { + private WorkOrderType? _workOrderType; + public string? Name { get; set; } public string? ExtraSafetyNote { get; set; } public List? Procedures { get; set; } public string? ServiceKey { get; set; } - public WorkOrderType? WorkOrderType { get; set; } + + public WorkOrderType? WorkOrderType + { + get => _workOrderType; + set + { + _workOrderType = value; + WorkOrderTypeSpecified = true; + } + } + + // True when the body carried workOrderType at all, including as null. + [System.Text.Json.Serialization.JsonIgnore] + public bool WorkOrderTypeSpecified { get; private set; } + public string? TemplateUrl { get; set; } public bool? IsActive { get; set; } } diff --git a/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs b/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs index 6f95833..ab5a111 100644 --- a/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs +++ b/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs @@ -105,7 +105,7 @@ namespace SeaHaven.Services.Implementation entity.ExtraSafetyNote = input.ExtraSafetyNote; if (input.ServiceKey != null) entity.ServiceKey = input.ServiceKey; - if (request.WorkOrderType.HasValue) + if (request.WorkOrderTypeSpecified) entity.WorkOrderType = request.WorkOrderType; if (input.TemplateUrl != null) entity.TemplateUrl = input.TemplateUrl; diff --git a/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs b/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs index cc53bbe..77e2160 100644 --- a/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs +++ b/SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs @@ -248,6 +248,42 @@ public class CompletionDocTemplateServiceTests Assert.Null(updated.CreatedByName); } + // Bodies are bound by System.Text.Json with the web defaults, as MVC does. + private static CompletionDocTemplateCreateDto JsonBody(string json) => + System.Text.Json.JsonSerializer.Deserialize( + json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + + [Fact] + public async Task Update_WorkOrderTypeExplicitNullClears_OmittedKeeps_ValueSets() + { + using var context = NewContext(); + context.CompletionDocTemplates.Add(new CompletionDocTemplate + { + Id = 8, + Name = "HVAC PM Completion", + ServiceKey = "HVAC", + WorkOrderType = WorkOrderType.PM, + TemplateUrl = "", + IsActive = true + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var omitted = JsonBody("""{"name":"HVAC Completion"}"""); + Assert.False(omitted.WorkOrderTypeSpecified); + var kept = await service.UpdateAsync(Admin(), 8, omitted, CancellationToken.None); + Assert.Equal(WorkOrderType.PM, kept.WorkOrderType); + + var cleared = await service.UpdateAsync( + Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":null}"""), CancellationToken.None); + Assert.Null(cleared.WorkOrderType); + Assert.Null((await context.CompletionDocTemplates.AsNoTracking().SingleAsync(t => t.Id == 8)).WorkOrderType); + + var set = await service.UpdateAsync( + Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":6}"""), CancellationToken.None); + Assert.Equal(WorkOrderType.Reactive, set.WorkOrderType); + } + [Fact] public async Task Update_MissingOrDeletedTemplate_ThrowsNotFound() {