From 3019e7109314030a644ec4c64190af098a9627bd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 10:56:47 -0300 Subject: [PATCH 01/16] feat(workorders): filter board search to an exact work-order id set GET /board/search accepts ids=1,2,3 (positive ints, deduplicated, at most 200). When present the result is exactly those work orders inside the caller's tenant and base scope; date, status, dispatcher, facet and text filters are ignored so none of them can hide a listed work order. Malformed or oversized lists are a 400. --- .../WorkOrderIdsFilterTests.cs | 217 ++++++++++++++++++ .../WorkOrderAdvancedSearchDataService.cs | 30 ++- .../WorkOrderAdvancedSearchModels.cs | 3 +- .../DTOs/WorkOrderBoardRequestDTOs.cs | 6 + SeaHaven.Services/Helpers/WorkOrderIdSet.cs | 38 +++ .../WorkOrderAdvancedSearchService.cs | 4 +- 6 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderIdSet.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs new file mode 100644 index 0000000..74a5882 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs @@ -0,0 +1,217 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope, +/// whatever other filters the board sends alongside. +/// +public class WorkOrderIdsFilterTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx) + => new( + new WorkOrderAdvancedSearchDataService(ctx), + new WorkOrderAccountResolver(new AccountDataService(ctx), new LocationDataService(ctx))); + + private static ClaimsPrincipal AccountUser(int accountId) + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, "test")); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + int accountId = 1, + string? assignTo = "disp-1", + LifecycleStatus? status = LifecycleStatus.Scheduled, + bool? deleted = null, + bool template = false) + => new() + { + Id = id, + AccountId = accountId, + InternalWONumber = $"3000000{id:0000}", + AssignTo = assignTo, + ScheduledDate = scheduled, + LifecycleStatus = status, + IsDeleted = deleted, + istemplate = template + }; + + private static void Seed(ApplicationDbContext ctx) + { + ctx.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22)), + Wo(2, null), // no schedule date + Wo(3, new DateTime(2025, 1, 6), status: LifecycleStatus.Completed), + Wo(4, new DateTime(2026, 9, 22), assignTo: "disp-2"), + Wo(5, new DateTime(2026, 9, 22)), // not requested + Wo(6, new DateTime(2026, 9, 22), accountId: 2), // another tenant + Wo(7, new DateTime(2026, 9, 22), deleted: true), + Wo(8, new DateTime(2026, 9, 22), template: true)); + ctx.SaveChanges(); + } + + [Fact] + public async Task Ids_ReturnExactlyThoseWorkOrders_IgnoringEveryOtherFilter() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "4,1,2,3", + // Filters the board may still carry; none of them may hide a listed work order. + DatePreset = WorkOrderAdvancedSearchDatePreset.ThisWeek, + Statuses = new List { LifecycleStatus.Scheduled }, + Dispatchers = new List { "disp-1" }, + Search = "no match anywhere", + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.TotalCount.Should().Be(4); + result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 }); + } + + [Fact] + public async Task Ids_NeverWidenTenantOrBaseScope() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "1,6,7,8", + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.Items.Select(row => row.Id).Should().Equal(1); + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task Ids_OnlyAnotherTenantsWorkOrders_ReturnsNothing() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "6" + }, AccountUser(1), "admin-1"); + + result.TotalCount.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task NoIds_KeepsTheExistingFilters() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 9, 21), + DateTo = new DateOnly(2026, 9, 25), + Dispatchers = new List { "disp-1" }, + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 5 }); + } + + [Theory] + [InlineData("1,abc")] + [InlineData("0")] + [InlineData("-3")] + [InlineData("1,,2")] + [InlineData("1.5")] + [InlineData("99999999999")] + public async Task MalformedIds_AreABadRequest(string ids) + { + var controller = NewController(); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids }); + + var badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().BeOfType().Which.Message.Should().Contain("ids"); + } + + [Fact] + public async Task MoreThanTheLimit_IsABadRequest() + { + var controller = NewController(); + var ids = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount + 1)); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids }); + + result.Should().BeOfType() + .Which.Value.Should().BeOfType() + .Which.Message.Should().Contain("200"); + } + + [Fact] + public void Parse_DeduplicatesBeforeCountingAndKeepsFirstSeenOrder() + { + var withDuplicates = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount).Concat(new[] { 5, 7 })); + + WorkOrderIdSet.ParseOrThrow(withDuplicates).Should().HaveCount(WorkOrderIdSet.MaxCount); + WorkOrderIdSet.ParseOrThrow(" 9, 3 ,9 ").Should().Equal(9, 3); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Parse_AbsentOrBlank_IsNoFilter(string? ids) + { + WorkOrderIdSet.ParseOrThrow(ids).Should().BeNull(); + } + + private static WorkOrderBoardController NewController() + { + var search = new WorkOrderAdvancedSearchService( + Mock.Of(), + Mock.Of()); + return new WorkOrderBoardController( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + search) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = AccountUser(1) } + } + }; + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index 74b5fb2..d6c5fec 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -19,6 +19,26 @@ namespace SeaHaven.DataServices.Implementation var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); if (query.AccountId.HasValue) baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value); + + // An exact id set (a link from an alert or a linked-work-orders list) shows those work + // orders whatever their date, status or owner; only the base and tenant scope still apply. + baseQuery = query.Ids is { Count: > 0 } ids + ? baseQuery.Where(w => ids.Contains(w.Id)) + : ApplyFilters(baseQuery, query); + + var totalCount = await baseQuery.CountAsync(); + + var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); + var paged = sorted + .Skip(query.Page * query.PageSize) + .Take(query.PageSize); + + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false); + return new WorkOrderAdvancedSearchResult(rows, totalCount); + } + + private IQueryable ApplyFilters(IQueryable baseQuery, WorkOrderAdvancedSearchQuery query) + { if (query.UnscheduledOnly) baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery); else @@ -47,15 +67,7 @@ namespace SeaHaven.DataServices.Implementation if (normalizedSearch != null) baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch); - var totalCount = await baseQuery.CountAsync(); - - var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); - var paged = sorted - .Skip(query.Page * query.PageSize) - .Take(query.PageSize); - - var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false); - return new WorkOrderAdvancedSearchResult(rows, totalCount); + return baseQuery; } private static IQueryable ApplySort(IQueryable query, string sortBy, string sortDir) diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 4ee175a..db5cff7 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -33,7 +33,8 @@ namespace SeaHaven.DataServices.Interfaces string SortBy, string SortDir, int? AccountId = null, - bool IncludeDateless = false); + bool IncludeDateless = false, + IReadOnlyList? Ids = null); public record WorkOrderAdvancedSearchResult( IReadOnlyList Rows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 38b4c24..4b47e9a 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -116,6 +116,12 @@ namespace SeaHaven.Services.DTOs public bool HasUplift { get; set; } public List? UpliftStatuses { get; set; } public bool MyWorkOrders { get; set; } + /// + /// Comma-separated work-order ids, at most distinct. + /// When present the result is exactly those work orders inside the caller's tenant scope, + /// and every other filter (dates, statuses, dispatchers, facets, search) is ignored. + /// + public string? Ids { get; set; } public string? SortBy { get; set; } public string? SortDir { get; set; } } diff --git a/SeaHaven.Services/Helpers/WorkOrderIdSet.cs b/SeaHaven.Services/Helpers/WorkOrderIdSet.cs new file mode 100644 index 0000000..73874b0 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderIdSet.cs @@ -0,0 +1,38 @@ +using System.Globalization; + +namespace SeaHaven.Services.Helpers +{ + /// + /// The board's exact work-order id filter: comma-separated positive integers, deduplicated, + /// at most distinct ids. + /// + public static class WorkOrderIdSet + { + public const int MaxCount = 200; + + /// + /// Null when is absent or blank. Throws + /// (a 400 on the board) for any token that is not a positive integer, or too many ids. + /// + public static IReadOnlyList? ParseOrThrow(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var ids = new List(); + var seen = new HashSet(); + foreach (var token in raw.Split(',')) + { + if (!int.TryParse(token.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) + throw new ArgumentException("ids must be a comma-separated list of positive work order ids."); + if (seen.Add(id)) + ids.Add(id); + } + + if (ids.Count > MaxCount) + throw new ArgumentException($"ids accepts at most {MaxCount} work orders."); + + return ids; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index 39f63dc..7ebb544 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -42,6 +42,7 @@ namespace SeaHaven.Services.Implementation query.DateTo); var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy); + var ids = WorkOrderIdSet.ParseOrThrow(query.Ids); var dataQuery = new WorkOrderAdvancedSearchQuery( WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), @@ -77,7 +78,8 @@ namespace SeaHaven.Services.Implementation query.IncludeDateless, query.DatePreset, query.DateFrom, - query.DateTo)); + query.DateTo), + ids); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow; From 7c097c27508adb0983b5ce17ceea3fb49829a752 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:00:22 -0300 Subject: [PATCH 02/16] 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 66a49ab9579f1bb86b1777dda80d397f7060f473 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:04:33 -0300 Subject: [PATCH 03/16] feat(auth): enforce one password policy on every password-setting path (SH-386) Both hosts now apply the same Identity password rule: at least 6 characters with one uppercase letter, one number and one special character. Change password requires an authenticated caller, verifies the current password before evaluating the new one, and reports a policy rejection separately from a wrong current password. --- .../AuthenticationControllerTests.cs | 38 +++- .../PasswordPolicyTests.cs | 180 ++++++++++++++++++ .../Controllers/AuthenticationController.cs | 18 +- .../Infrastructure/IdentityRegistration.cs | 24 +++ Api.SeaHavenIndustries/Program.cs | 8 +- .../Auth/IdentityPasswordPolicy.cs | 27 +++ SeaHaven.Services/DTOs/IdentityDTOs.cs | 12 ++ .../Implementation/AuthenticationService.cs | 21 +- .../Interfaces/IAuthenticationService.cs | 2 +- SeaHavenIndustries/Program.cs | 9 +- 10 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs create mode 100644 Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs create mode 100644 Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 99d5ab8..4187a24 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -117,11 +117,11 @@ public class AuthenticationControllerTests { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) - .ReturnsAsync(true); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded }); var controller = NewController(service, "42"); - var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None); + var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; @@ -130,11 +130,11 @@ public class AuthenticationControllerTests } [Fact] - public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() + public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect }); var controller = NewController(service, "42"); @@ -143,6 +143,36 @@ public class AuthenticationControllerTests var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Old Password is incorrect"); + response.Message.Should().Be("Current password is incorrect"); + } + + [Fact] + public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny())) + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected }); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Password does not meet requirements"); + response.Message.Should().Be( + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."); + } + + [Fact] + public void ChangePassword_RequiresAuthenticatedCaller() + { + var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!; + + method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true) + .Should().NotBeEmpty(); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs new file mode 100644 index 0000000..946a24d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -0,0 +1,180 @@ +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Exercises the password rule through the same Identity registration the API +/// host uses, so these assertions describe the rule that runs in production. +/// +public sealed class PasswordPolicyTests : IAsyncDisposable +{ + private const string CurrentPassword = "Current1!"; + private readonly ServiceProvider _provider; + private readonly AsyncServiceScope _scope; + + public PasswordPolicyTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddSeaHavenIdentity(); + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateAsyncScope(); + } + + private UserManager UserManager => + _scope.ServiceProvider.GetRequiredService>(); + + [Theory] + [InlineData("Ab1!x", "PasswordTooShort")] + [InlineData("abc12!", "PasswordRequiresUpper")] + [InlineData("Abcde!", "PasswordRequiresDigit")] + [InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")] + public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Select(error => error.Code).Should().Equal(expectedCode); + } + + [Theory] + [InlineData("Abc1!x")] + [InlineData("ABC12!")] + public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Should().BeEmpty(); + } + + [Fact] + public void Registration_AppliesSharedPolicyOptions() + { + var options = _scope.ServiceProvider.GetRequiredService>().Value.Password; + + options.RequiredLength.Should().Be(6); + options.RequireUppercase.Should().BeTrue(); + options.RequireDigit.Should().BeTrue(); + options.RequireNonAlphanumeric.Should().BeTrue(); + options.RequireLowercase.Should().BeFalse(); + } + + [Fact] + public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.PasswordRejected); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.Succeeded); + var reloaded = await UserManager.FindByIdAsync(user.Id); + (await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CancelledToken_Throws() + { + var service = NewAuthenticationService(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode() + { + var user = await CreateUserAsync(); + var forget = new Mock(); + forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny())) + .ReturnsAsync(true); + forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny())) + .ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" }); + var service = NewAuthenticationService(forget); + + var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None); + + reset.Should().BeFalse(); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private async Task> ValidateAsync(ApplicationUser user, string password) + { + var errors = new List(); + foreach (var validator in UserManager.PasswordValidators) + { + var result = await validator.ValidateAsync(UserManager, user, password); + errors.AddRange(result.Errors); + } + + return errors; + } + + private async Task CreateUserAsync() + { + var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" }; + var created = await UserManager.CreateAsync(user, CurrentPassword); + created.Succeeded.Should().BeTrue(); + return user; + } + + private AuthenticationService NewAuthenticationService(Mock? forget = null) => + new( + UserManager, + Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), + Mock.Of(), + (forget ?? new Mock()).Object, + Mock.Of()); + + public async ValueTask DisposeAsync() + { + await _scope.DisposeAsync(); + await _provider.DisposeAsync(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 12fd8fa..7b6569a 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -55,20 +55,26 @@ namespace Api.SeaHavenIndustries.Controllers } + [Authorize] [Route("ChangePassword")] [HttpPost] public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); - if (succeeded) + var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken); + return result.Status switch { - return Ok(new Response { Status = "Success ", Message = "Password successfully changed" }); - } - else - return BadRequest(new Response { Status = "Old Password is incorrect" }); + ChangePasswordStatus.Succeeded => + Ok(new Response { Status = "Success ", Message = "Password successfully changed" }), + ChangePasswordStatus.PasswordRejected => + BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }), + _ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" }) + }; } + private const string PasswordRequirementsMessage = + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."; + [HttpPost] [Route("UpdateProfile")] public async Task UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) diff --git a/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs new file mode 100644 index 0000000..752e494 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs @@ -0,0 +1,24 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public static class IdentityRegistration + { + /// + /// Registers ASP.NET Identity for the API with the shared password policy. + /// Program.cs and the behavior tests both compose Identity through this + /// method so the rule under test is the rule that runs. + /// + public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services) + { + return services.AddIdentity(options => + { + options.User.RequireUniqueEmail = false; + IdentityPasswordPolicy.Apply(options.Password); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6baf11f..0151543 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Infrastructure; using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Observability; using Api.SeaHavenIndustries.Options; @@ -43,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration; builder.Services.AddDbContext(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); -builder.Services.AddIdentity(options => -{ - options.User.RequireUniqueEmail = false; -}) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); +builder.Services.AddSeaHavenIdentity(); builder.Services.AddControllers(options => { diff --git a/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs new file mode 100644 index 0000000..a0161ff --- /dev/null +++ b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Identity; + +namespace Data.SeaHavenIndustries +{ + /// + /// The single password rule for every surface that sets a password: at least + /// six characters with one uppercase letter, one number, and one special + /// character. Lowercase letters are deliberately not required so the server + /// accepts exactly what the four-item checklist in the web app marks as met. + /// + public static class IdentityPasswordPolicy + { + public const int MinimumLength = 6; + + public static void Apply(PasswordOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.RequiredLength = MinimumLength; + options.RequireUppercase = true; + options.RequireDigit = true; + options.RequireNonAlphanumeric = true; + options.RequireLowercase = false; + options.RequiredUniqueChars = 1; + } + } +} diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs index ad3b8cb..e959478 100644 --- a/SeaHaven.Services/DTOs/IdentityDTOs.cs +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -11,6 +11,18 @@ namespace SeaHaven.Services.DTOs public string Id { get; set; } = string.Empty; } + public enum ChangePasswordStatus + { + Succeeded, + CurrentPasswordIncorrect, + PasswordRejected + } + + public sealed class ChangePasswordResultDTO + { + public ChangePasswordStatus Status { get; init; } + } + public class UpdateProfileRequestDTO { public string? Name { get; set; } diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index d86f19a..e293276 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -80,16 +80,27 @@ namespace SeaHaven.Services.Implementation return null; } - public async Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken) + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var user = await _userManager.FindByIdAsync(userId); - if (user == null) - return false; + if (user == null || user.IsDeleted == true) + return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); - var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? ""); - return result.Succeeded; + // The current password is verified before the new one is evaluated, so a + // caller without it learns nothing about the policy outcome. + if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? "")) + return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); + + var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? ""); + return ChangePasswordResult(result.Succeeded + ? ChangePasswordStatus.Succeeded + : ChangePasswordStatus.PasswordRejected); } + private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) => + new() { Status = status }; + public async Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) { var exists = await _userDataService.UpdateProfileAsync( diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index 426eb5f..e4ffc9b 100644 --- a/SeaHaven.Services/Interfaces/IAuthenticationService.cs +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces public interface IAuthenticationService { Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); - Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); + Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken); Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); Task VerifyCodeAsync(string code, CancellationToken cancellationToken); diff --git a/SeaHavenIndustries/Program.cs b/SeaHavenIndustries/Program.cs index 48b76c3..5a33b68 100644 --- a/SeaHavenIndustries/Program.cs +++ b/SeaHavenIndustries/Program.cs @@ -31,7 +31,8 @@ builder.Services.AddAuthentication(options => .AddIdentityCookies(); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); -builder.Services.AddDbContext(options => { +builder.Services.AddDbContext(options => +{ options.UseSqlServer(connectionString); }, ServiceLifetime.Transient); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); @@ -39,11 +40,7 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddIdentityCore(options => { options.SignIn.RequireConfirmedAccount = true; - options.Password.RequireDigit = true; - options.Password.RequireLowercase = false; - options.Password.RequireUppercase = false; - options.Password.RequireNonAlphanumeric = true; - options.Password.RequiredLength = 8; + IdentityPasswordPolicy.Apply(options.Password); }).AddRoles().AddEntityFrameworkStores().AddSignInManager() .AddDefaultTokenProviders(); From 14c8e46dd0a7a1b779d9712556af6ef763debdd6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:16:21 -0300 Subject: [PATCH 04/16] feat(notifications): SEV response-window alerts and breach acknowledgement Reactive/Emergency work orders with a SEV 1-5 level are timed from their creation against the SEV Respond deadline (2/4/8/24/72 hours, one backend table). From 50% they are at risk: a dismissable High row in the "SLA at Risk" section and an entry in the feed's slaAtRisk set with the server clock (start, deadline, percent) for the banner and toast. From 100% they are a Critical acknowledge row that only acknowledging removes. POST /api/notifications/sla/{id}/acknowledge records who and when as a work-order audit entry ("SLA breach acknowledged by "), scoped to the caller's feed audience: 404 outside it, 409 before the deadline, 204 when recorded or already recorded. A later severity change is a new breach. --- .../NotificationFeedServiceTests.cs | 6 +- .../NotificationSlaTests.cs | 402 ++++++++++++++++++ .../Controllers/NotificationsController.cs | 42 +- .../NotificationFeedDataService.cs | 85 ++++ .../INotificationFeedDataService.cs | 17 + .../Models/NotificationFeedReadModels.cs | 35 ++ .../DTOs/NotificationFeedDTOs.cs | 17 + .../Helpers/NotificationAudience.cs | 27 ++ .../Helpers/SlaResponseWindows.cs | 55 +++ .../Implementation/NotificationFeedService.cs | 84 +++- .../SlaBreachAcknowledgementService.cs | 76 ++++ .../ISlaBreachAcknowledgementService.cs | 25 ++ 12 files changed, 853 insertions(+), 18 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs create mode 100644 SeaHaven.Services/Helpers/NotificationAudience.cs create mode 100644 SeaHaven.Services/Helpers/SlaResponseWindows.cs create mode 100644 SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs create mode 100644 SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs diff --git a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs index 9e8910e..6f483f1 100644 --- a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs @@ -7,11 +7,13 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Moq; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -546,7 +548,7 @@ public class NotificationFeedServiceTests public async Task Controller_MapsForbiddenScopeTo403() { using var context = NewContext(); - var controller = new NotificationsController(NewService(context)) + var controller = new NotificationsController(NewService(context), Mock.Of()) { ControllerContext = new ControllerContext { @@ -565,7 +567,7 @@ public class NotificationFeedServiceTests using var context = NewContext(); context.workOrders.Add(Open(1)); await context.SaveChangesAsync(); - var controller = new NotificationsController(NewService(context)) + var controller = new NotificationsController(NewService(context), Mock.Of()) { ControllerContext = new ControllerContext { diff --git a/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs b/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs new file mode 100644 index 0000000..2df8a3f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs @@ -0,0 +1,402 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SEV response-window alerts in the Notification Center: at risk from 50% of the window (banner set and a +/// dismissable row), breached from 100% (an acknowledge row that only acknowledging removes). +/// +public class NotificationSlaTests +{ + private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc); + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(Now); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAccountResolver Resolver(ApplicationDbContext context) + => new(new AccountDataService(context), new LocationDataService(context)); + + private static NotificationFeedService NewFeed(ApplicationDbContext context) + => new( + new NotificationFeedDataService(context), + new VendorOperationsDataService(context, new DispatchDataService(context)), + Resolver(context), + new FixedTimeProvider()); + + private static SlaBreachAcknowledgementService NewAcknowledgement(ApplicationDbContext context) + => new( + new NotificationFeedDataService(context), + new WorkOrderAuditDataService(context), + new UserDataService(context), + Resolver(context), + new FixedTimeProvider()); + + private static ClaimsPrincipal User(int accountId, string role, string userId = "disp-1") + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static WorkOrder Sla( + int id, + TimeSpan age, + string? severity = "1", + WorkOrderType? type = WorkOrderType.Reactive, + int accountId = 1, + string? assignTo = "disp-1", + DateTime? scheduled = null, + LifecycleStatus status = LifecycleStatus.Scheduled) + => new() + { + Id = id, + AccountId = accountId, + AssignTo = assignTo, + InternalWONumber = $"{1000 + id}", + WorkOrderType = type, + Severity = severity, + CreatedDate = Now - age, + ScheduledDate = scheduled, + LifecycleStatus = status + }; + + private static TimeSpan Window(int severity) => SlaResponseWindows.Respond[severity]; + + private static NotificationSectionDto? SlaSection(NotificationFeedDto feed) + => feed.Sections.SingleOrDefault(section => section.Reason == NotificationReasons.Sla); + + private static IEnumerable ItemIds(NotificationFeedDto feed) + => SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty(); + + [Fact] + public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge() + { + SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes) + .Should().Equal(new Dictionary + { + [1] = 120, + [2] = 240, + [3] = 480, + [4] = 1440, + [5] = 4320 + }); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task Thresholds_AtRiskFromHalfTheWindow_BreachedFromTheWholeWindow(int severity) + { + using var context = NewContext(); + var second = TimeSpan.FromSeconds(1); + var window = Window(severity); + var level = severity.ToString(); + context.workOrders.AddRange( + Sla(1, window / 2 - second, level), + Sla(2, window / 2, level), + Sla(3, window / 2 + second, level), + Sla(4, window - second, level), + Sla(5, window, level), + Sla(6, window + second, level)); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + feed.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 2, 3, 4 }); + ItemIds(feed).Should().BeEquivalentTo( + "sla-at-risk-2", "sla-at-risk-3", "sla-at-risk-4", "sla-breach-5", "sla-breach-6"); + + var section = SlaSection(feed)!; + section.Label.Should().Be("SLA at Risk"); + section.Count.Should().Be(5); + section.Severity.Should().Be(NotificationSeverities.Critical); + section.Items.Where(item => item.Id.StartsWith("sla-breach-")).Should().OnlyContain(item => + item.RowType == NotificationRowTypes.Acknowledge + && item.Severity == NotificationSeverities.Critical + && item.Title.EndsWith("missed its response deadline")); + section.Items.Where(item => item.Id.StartsWith("sla-at-risk-")).Should().OnlyContain(item => + item.RowType == NotificationRowTypes.Dismissable + && item.Severity == NotificationSeverities.High + && item.Title.EndsWith("is at risk of missing its response deadline")); + } + + [Fact] + public async Task AtRisk_CarriesTheServerComputedClock() + { + using var context = NewContext(); + context.workOrders.Add(Sla(7, TimeSpan.FromMinutes(90), "1")); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + var atRisk = feed.SlaAtRisk.Should().ContainSingle().Subject; + atRisk.Number.Should().Be("1007"); + atRisk.Severity.Should().Be(1); + atRisk.StartedAt.Should().Be(Now.AddMinutes(-90)); + atRisk.DeadlineAt.Should().Be(Now.AddMinutes(30)); + atRisk.DeadlineAt.Kind.Should().Be(DateTimeKind.Utc); + atRisk.PercentElapsed.Should().Be(75); + SlaSection(feed)!.Items.Single().Title.Should().Be("WO #1007 is at risk of missing its response deadline"); + } + + [Fact] + public async Task OnlyOpenReactiveOrEmergencyWorkOrdersWithASeverityLevelAreTracked() + { + using var context = NewContext(); + var late = Window(1) * 2; + context.workOrders.AddRange( + Sla(1, late, "1", WorkOrderType.Reactive), + Sla(2, late, "2", WorkOrderType.Emergency), + Sla(3, late, "1", WorkOrderType.PM), + Sla(4, late, "1", WorkOrderType.Inspection), + Sla(5, late, "1", type: null), + Sla(6, late, severity: null), + Sla(7, late, severity: "9"), + Sla(8, late, "1", status: LifecycleStatus.Completed), + Sla(9, late, "1", status: LifecycleStatus.Canceled)); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2"); + } + + [Fact] + public async Task TheClockStartsAtCreation_WhateverTheScheduledDate() + { + using var context = NewContext(); + context.workOrders.AddRange( + Sla(1, Window(3) + TimeSpan.FromMinutes(1), "3", scheduled: null), + // Scheduled next week, but logged three days ago: the deadline is long gone. + Sla(2, TimeSpan.FromDays(3), "4", scheduled: Now.AddDays(7)), + // Scheduled days ago, but logged ten minutes ago: nothing is due yet. + Sla(3, TimeSpan.FromMinutes(10), "4", scheduled: Now.AddDays(-5))); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2"); + feed.SlaAtRisk.Should().BeEmpty(); + } + + [Fact] + public async Task Dispatcher_OnlySeesTheirOwnWorkOrders_AndTheAccountStaysTheBoundary() + { + using var context = NewContext(); + var atRisk = Window(2) * 3 / 4; + context.workOrders.AddRange( + Sla(1, atRisk, "2", assignTo: "disp-1"), + Sla(2, atRisk, "2", assignTo: "disp-2"), + Sla(3, atRisk, "2", assignTo: null), + Sla(4, atRisk, "2", assignTo: "disp-1", accountId: 2)); + await context.SaveChangesAsync(); + + var dispatcher = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher", "disp-1"), CancellationToken.None); + var admin = await NewFeed(context).GetFeedAsync(User(1, "Admin", "admin-1"), CancellationToken.None); + var otherAccount = await NewFeed(context).GetFeedAsync(User(2, "Admin", "admin-2"), CancellationToken.None); + + dispatcher.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(1); + ItemIds(dispatcher).Should().Equal("sla-at-risk-1"); + admin.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 1, 2, 3 }); + otherAccount.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(4); + } + + [Fact] + public async Task Breaches_AreCappedSeparately_SoAtRiskRowsAlwaysShow() + { + using var context = NewContext(); + for (var id = 1; id <= NotificationFeedService.SectionItemLimit + 10; id++) + context.workOrders.Add(Sla(id, Window(1) + TimeSpan.FromMinutes(id), "1")); + context.workOrders.Add(Sla(500, Window(5) * 3 / 4, "5")); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + var section = SlaSection(feed)!; + section.Count.Should().Be(NotificationFeedService.SectionItemLimit + 11); + section.Items.Count(item => item.RowType == NotificationRowTypes.Acknowledge) + .Should().Be(NotificationFeedService.SectionItemLimit); + section.Items.Should().Contain(item => item.Id == "sla-at-risk-500"); + // The newest breaches are kept. + section.Items.Should().Contain(item => item.Id == "sla-breach-1"); + section.Items.Should().NotContain(item => item.Id == $"sla-breach-{NotificationFeedService.SectionItemLimit + 10}"); + } + + [Fact] + public async Task Acknowledge_RecordsWhoAndWhenInTheAuditHistory_AndRemovesTheRow() + { + using var context = NewContext(); + context.Users.Add(new ApplicationUser { Id = "disp-1", UserName = "jane", FirstName = "Jane", LastName = "Doe" }); + context.workOrders.AddRange( + Sla(1, Window(1) + TimeSpan.FromMinutes(5), "1"), + Sla(2, Window(1) + TimeSpan.FromMinutes(9), "1")); + await context.SaveChangesAsync(); + var user = User(1, "Dispatcher", "disp-1"); + + var outcome = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); + + outcome.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged); + var audit = await context.WorkOrderAuditLogs.SingleAsync(); + audit.WorkOrderId.Should().Be(1); + audit.UserId.Should().Be("disp-1"); + audit.CreatedAt.Should().Be(Now); + audit.Action.Should().Be("SLA breach acknowledged by Jane Doe"); + audit.FieldName.Should().Be(SlaBreachAcknowledgementAudit.FieldName); + audit.NewValue.Should().Be("1"); + audit.EventType.Should().Be("system"); + + var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None); + ItemIds(feed).Should().Equal("sla-breach-2"); + SlaSection(feed)!.Count.Should().Be(1); + + var again = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); + again.Should().Be(SlaBreachAcknowledgementOutcome.AlreadyAcknowledged); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Acknowledgement_CoversOnlyTheSeverityThatWasBreached() + { + using var context = NewContext(); + var workOrder = Sla(1, TimeSpan.FromHours(30), "2"); + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + var user = User(1, "Dispatcher", "disp-1"); + await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); + + workOrder.Severity = "4"; + await context.SaveChangesAsync(); + var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None); + + ItemIds(feed).Should().Equal("sla-breach-1"); + } + + [Fact] + public async Task Acknowledge_IsRefusedOutsideTheCallersFeed() + { + using var context = NewContext(); + var late = Window(1) * 2; + context.workOrders.AddRange( + Sla(1, late, "1", accountId: 2, assignTo: "disp-1"), + Sla(2, late, "1", assignTo: "disp-2"), + Sla(3, late, "1", status: LifecycleStatus.Completed), + Sla(4, late, "1", WorkOrderType.PM)); + await context.SaveChangesAsync(); + var service = NewAcknowledgement(context); + var dispatcher = User(1, "Dispatcher", "disp-1"); + + (await service.AcknowledgeAsync(dispatcher, 1, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(User(1, "Admin", "admin-1"), 1, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(dispatcher, 2, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(dispatcher, 3, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(dispatcher, 4, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); + + var otherAccountAdmin = User(2, "Admin", "admin-2"); + (await service.AcknowledgeAsync(otherAccountAdmin, 1, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged); + } + + [Fact] + public async Task Acknowledge_BeforeTheDeadlineIsRefused() + { + using var context = NewContext(); + context.workOrders.Add(Sla(1, Window(1) * 3 / 4, "1")); + await context.SaveChangesAsync(); + + var outcome = await NewAcknowledgement(context) + .AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, CancellationToken.None); + + outcome.Should().Be(SlaBreachAcknowledgementOutcome.NotBreached); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Acknowledge_WithoutANotificationRole_FailsClosed() + { + using var context = NewContext(); + context.workOrders.Add(Sla(1, Window(1) * 2, "1")); + await context.SaveChangesAsync(); + + var act = () => NewAcknowledgement(context).AcknowledgeAsync(User(1, "Vendor"), 1, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Theory] + [InlineData(1, "Dispatcher", 1, StatusCodes.Status204NoContent)] + [InlineData(2, "Admin", 1, StatusCodes.Status404NotFound)] + [InlineData(1, "Dispatcher", 2, StatusCodes.Status409Conflict)] + [InlineData(1, "Vendor", 1, StatusCodes.Status403Forbidden)] + public async Task Controller_MapsAcknowledgeOutcomes(int accountId, string role, int workOrderId, int expectedStatus) + { + using var context = NewContext(); + context.workOrders.AddRange( + Sla(1, Window(1) * 2, "1"), + Sla(2, Window(1) * 3 / 4, "1")); + await context.SaveChangesAsync(); + var controller = new NotificationsController(NewFeed(context), NewAcknowledgement(context)) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = User(accountId, role, "disp-1") } + } + }; + + var result = await controller.AcknowledgeSlaBreach(workOrderId, CancellationToken.None); + + result.Should().BeAssignableTo() + .Which.StatusCode.Should().Be(expectedStatus); + } + + [Fact] + public async Task Acknowledge_ForwardsCancellation() + { + using var context = NewContext(); + context.workOrders.Add(Sla(1, Window(1) * 2, "1")); + await context.SaveChangesAsync(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => NewAcknowledgement(context) + .AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, cancellation.Token); + + await act.Should().ThrowAsync(); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/NotificationsController.cs b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs index 5eff188..f091119 100644 --- a/Api.SeaHavenIndustries/Controllers/NotificationsController.cs +++ b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs @@ -11,10 +11,50 @@ namespace Api.SeaHavenIndustries.Controllers public class NotificationsController : ControllerBase { private readonly INotificationFeedService _feedService; + private readonly ISlaBreachAcknowledgementService _slaAcknowledgement; - public NotificationsController(INotificationFeedService feedService) + public NotificationsController( + INotificationFeedService feedService, + ISlaBreachAcknowledgementService slaAcknowledgement) { _feedService = feedService; + _slaAcknowledgement = slaAcknowledgement; + } + + /// + /// Acknowledges the missed response deadline of one work order in the caller's Notification Center. + /// 204 when recorded or already recorded; 404 when the caller's feed does not cover the work order; + /// 409 when its deadline has not been missed. + /// + [HttpPost("sla/{workOrderId:int}/acknowledge")] + public async Task AcknowledgeSlaBreach(int workOrderId, CancellationToken cancellationToken) + { + try + { + var outcome = await _slaAcknowledgement.AcknowledgeAsync(User, workOrderId, cancellationToken); + return outcome switch + { + SlaBreachAcknowledgementOutcome.NotFound => NotFound(new + { + code = "NotFound", + message = "Work order not found." + }), + SlaBreachAcknowledgementOutcome.NotBreached => Conflict(new + { + code = "NotBreached", + message = "This work order has not missed its response deadline." + }), + _ => NoContent() + }; + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } } [HttpGet] diff --git a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs index 5b5748f..f3b0986 100644 --- a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs +++ b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs @@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Implementation // Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected. private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" }; + private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" }; + private const string AssignToAuditField = "AssignTo"; private const string LegacyAssignedToAuditField = "AssignedTo"; @@ -176,6 +178,89 @@ namespace SeaHaven.DataServices.Implementation return await candidates.Take(limit).ToListAsync(cancellationToken); } + public async Task GetSlaCandidatesAsync( + NotificationFeedScope scope, + NotificationSlaCutoffs atRisk, + NotificationSlaCutoffs breached, + int breachedLimit, + CancellationToken cancellationToken) + { + var eligible = SlaEligibleWorkOrders(scope); + + // Bounded by the longest window: nothing created more than 72 hours ago is still at risk. + var atRiskItems = await ProjectSla(CreatedAfter(CreatedAtOrBefore(eligible, atRisk), breached)) + .OrderBy(candidate => candidate.CreatedAt) + .ThenBy(candidate => candidate.Id) + .ToListAsync(cancellationToken); + + // Breaches stay until acknowledged, so legacy open work orders can make this set large. + var unacknowledged = CreatedAtOrBefore(eligible, breached) + .Where(w => !_context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id + && log.FieldName == SlaBreachAcknowledgementAudit.FieldName + && log.NewValue == w.Severity)); + var breachedTotal = await unacknowledged.CountAsync(cancellationToken); + var breachedItems = breachedTotal == 0 + ? new List() + : await ProjectSla(unacknowledged) + .OrderByDescending(candidate => candidate.CreatedAt) + .ThenByDescending(candidate => candidate.Id) + .Take(breachedLimit) + .ToListAsync(cancellationToken); + + return new NotificationSlaCandidates + { + AtRisk = atRiskItems, + Breached = breachedItems, + BreachedTotal = breachedTotal + }; + } + + public Task GetSlaCandidateAsync( + NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken) + => ProjectSla(SlaEligibleWorkOrders(scope).Where(w => w.Id == workOrderId)) + .FirstOrDefaultAsync(cancellationToken); + + // Same rule as the board's severity facet: Reactive or Emergency with a stored SEV level "1"–"5". + private IQueryable SlaEligibleWorkOrders(NotificationFeedScope scope) + => OpenWorkOrders(scope).Where(w => + (w.WorkOrderType == WorkOrderType.Reactive || w.WorkOrderType == WorkOrderType.Emergency) + && SlaSeverities.Contains(w.Severity ?? "") + && w.CreatedDate != null); + + private static IQueryable CreatedAtOrBefore(IQueryable workOrders, NotificationSlaCutoffs cutoffs) + { + var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5); + return workOrders.Where(w => + (w.Severity == "1" && w.CreatedDate <= sev1) + || (w.Severity == "2" && w.CreatedDate <= sev2) + || (w.Severity == "3" && w.CreatedDate <= sev3) + || (w.Severity == "4" && w.CreatedDate <= sev4) + || (w.Severity == "5" && w.CreatedDate <= sev5)); + } + + private static IQueryable CreatedAfter(IQueryable workOrders, NotificationSlaCutoffs cutoffs) + { + var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5); + return workOrders.Where(w => + (w.Severity == "1" && w.CreatedDate > sev1) + || (w.Severity == "2" && w.CreatedDate > sev2) + || (w.Severity == "3" && w.CreatedDate > sev3) + || (w.Severity == "4" && w.CreatedDate > sev4) + || (w.Severity == "5" && w.CreatedDate > sev5)); + } + + private IQueryable ProjectSla(IQueryable workOrders) + => workOrders.Select(w => new NotificationSlaCandidate + { + Id = w.Id, + Number = w.InternalWONumber ?? w.WorkerOrderNumber, + Severity = w.Severity!, + CreatedAt = w.CreatedDate!.Value, + BreachAcknowledged = _context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id + && log.FieldName == SlaBreachAcknowledgementAudit.FieldName + && log.NewValue == w.Severity) + }); + private IQueryable ScopedWorkOrders(int? accountId) { var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); diff --git a/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs index 1976764..b1aed65 100644 --- a/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs +++ b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs @@ -48,5 +48,22 @@ namespace SeaHaven.DataServices.Interfaces /// Task> GetUpliftDecisionsAsync( NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken); + + /// + /// Open Reactive/Emergency work orders with a SEV 1–5 level, split by creation time: at risk when + /// created at or before but after , breached when + /// created at or before and not yet acknowledged for their current level. + /// Breaches are the newest; the at-risk set is bounded by the longest window. + /// + Task GetSlaCandidatesAsync( + NotificationFeedScope scope, + NotificationSlaCutoffs atRisk, + NotificationSlaCutoffs breached, + int breachedLimit, + CancellationToken cancellationToken); + + /// The work order as an SLA candidate inside , or null when it is not one. + Task GetSlaCandidateAsync( + NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs index d404be2..53165ec 100644 --- a/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs +++ b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs @@ -64,3 +64,38 @@ public sealed class NotificationUpliftDecisionCandidate public string? DecisionNote { get; init; } public DateTime DecidedAt { get; init; } } + +/// +/// Per SEV level, the latest work-order creation time that has reached a response-window threshold. +/// The business service computes them; the query only compares creation times. +/// +public sealed record NotificationSlaCutoffs(DateTime Sev1, DateTime Sev2, DateTime Sev3, DateTime Sev4, DateTime Sev5); + +/// +/// The work-order audit entry that records an SLA breach acknowledgement: who (UserId), when (CreatedAt) +/// and the SEV level whose deadline was missed (NewValue). It is also what keeps the breach out of the feed. +/// +public static class SlaBreachAcknowledgementAudit +{ + public const string FieldName = "SlaBreachAcknowledged"; +} + +/// An open Reactive/Emergency work order with a SEV 1–5 level and a creation time. +public sealed class NotificationSlaCandidate +{ + public int Id { get; init; } + public string? Number { get; init; } + public string Severity { get; init; } = ""; + public DateTime CreatedAt { get; init; } + /// A breach of the work order's current severity has already been acknowledged. + public bool BreachAcknowledged { get; init; } +} + +public sealed class NotificationSlaCandidates +{ + /// Every work order past half its window but not past the whole window. + public IReadOnlyList AtRisk { get; init; } = Array.Empty(); + /// Unacknowledged breaches: the newest slice, plus the unbounded total. + public IReadOnlyList Breached { get; init; } = Array.Empty(); + public int BreachedTotal { get; init; } +} diff --git a/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs index a2ab7e9..10d8146 100644 --- a/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs +++ b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs @@ -126,9 +126,26 @@ namespace SeaHaven.Services.DTOs public IReadOnlyList Items { get; init; } = Array.Empty(); } + /// + /// A work order past half of its SEV response window and not yet past the whole window, with the + /// server-computed clock. Drives the top-of-app banner and the one-time 50% toast. + /// + public sealed class NotificationSlaWorkOrderDto + { + public int Id { get; init; } + public string? Number { get; init; } + public int Severity { get; init; } + /// The work order's creation time; the response clock never starts at the scheduled date. + public DateTime StartedAt { get; init; } + public DateTime DeadlineAt { get; init; } + public int PercentElapsed { get; init; } + } + public sealed class NotificationFeedDto { public DateTime GeneratedAt { get; init; } public IReadOnlyList Sections { get; init; } = Array.Empty(); + /// Every at-risk work order in the feed's scope, oldest first; not capped like section items. + public IReadOnlyList SlaAtRisk { get; init; } = Array.Empty(); } } diff --git a/SeaHaven.Services/Helpers/NotificationAudience.cs b/SeaHaven.Services/Helpers/NotificationAudience.cs new file mode 100644 index 0000000..e35d76b --- /dev/null +++ b/SeaHaven.Services/Helpers/NotificationAudience.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + /// Whose work orders the Notification Center covers for the signed-in user. + public static class NotificationAudience + { + // Dispatchers see their own work orders. Roles that already see every dispatcher's + // work on the dashboard see the whole account, including work nobody owns yet. + public static (string? DispatcherId, bool SeesUnassigned) Resolve(ClaimsPrincipal user) + { + if (user.IsInRole("Dispatcher")) + { + var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + return (dispatcherId, false); + } + + if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler")) + return (null, true); + + throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role."); + } + } +} diff --git a/SeaHaven.Services/Helpers/SlaResponseWindows.cs b/SeaHaven.Services/Helpers/SlaResponseWindows.cs new file mode 100644 index 0000000..35ab817 --- /dev/null +++ b/SeaHaven.Services/Helpers/SlaResponseWindows.cs @@ -0,0 +1,55 @@ +namespace SeaHaven.Services.Helpers +{ + public enum SlaState + { + OnTrack, + /// At least half of the response window has elapsed. + AtRisk, + /// The whole response window has elapsed: the response deadline was missed. + Breached + } + + /// Where one work order stands against its SEV response window. + public sealed record SlaClock(int Severity, DateTime StartedAt, DateTime DeadlineAt, int PercentElapsed, SlaState State); + + /// + /// Respond deadlines from the client's SEV table, the same values the severity badge tooltip shows. + /// They apply to Reactive and Emergency work orders with a severity, and the clock starts when the + /// work order is created, never at its scheduled date. The table's "next day if after hours" note + /// is not modelled: the clock always counts the plain hours. + /// + public static class SlaResponseWindows + { + public static readonly IReadOnlyDictionary Respond = new Dictionary + { + [1] = TimeSpan.FromHours(2), + [2] = TimeSpan.FromHours(4), + [3] = TimeSpan.FromHours(8), + [4] = TimeSpan.FromHours(24), + [5] = TimeSpan.FromHours(72) + }; + + /// The latest creation time at which a work order of this level has used half its window. + public static DateTime AtRiskCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity] / 2; + + /// The latest creation time at which a work order of this level has missed its deadline. + public static DateTime BreachedCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity]; + + /// Null when the severity is not a SEV 1–5 level. + public static SlaClock? Evaluate(string? severity, DateTime createdAt, DateTime now) + { + var level = WorkOrderSeverityRules.ParseLevel(severity); + if (level is not int sev) + return null; + + var window = Respond[sev]; + var startedAt = DateTime.SpecifyKind(createdAt, DateTimeKind.Utc); + var elapsed = now - startedAt; + var state = elapsed >= window + ? SlaState.Breached + : elapsed >= window / 2 ? SlaState.AtRisk : SlaState.OnTrack; + var percent = (int)Math.Floor(Math.Max(elapsed.Ticks, 0) * 100d / window.Ticks); + return new SlaClock(sev, startedAt, startedAt + window, percent, state); + } + } +} diff --git a/SeaHaven.Services/Implementation/NotificationFeedService.cs b/SeaHaven.Services/Implementation/NotificationFeedService.cs index ff12e45..9b08cc9 100644 --- a/SeaHaven.Services/Implementation/NotificationFeedService.cs +++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs @@ -41,11 +41,13 @@ namespace SeaHaven.Services.Implementation public async Task GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); - var (dispatcherId, seesUnassigned) = ResolveAudience(user); + var (dispatcherId, seesUnassigned) = NotificationAudience.Resolve(user); var scope = new NotificationFeedScope(accountId, dispatcherId); var now = _timeProvider.GetUtcNow().UtcDateTime; var sections = new List<(string Reason, int Count, IReadOnlyList Items)>(); + var (slaSection, slaAtRisk) = await SlaAsync(scope, now, cancellationToken); + sections.Add(slaSection); if (seesUnassigned) sections.Add(await UnassignedAsync(accountId, now, cancellationToken)); sections.Add(await NoVendorAsync(scope, now, cancellationToken)); @@ -59,28 +61,80 @@ namespace SeaHaven.Services.Implementation return new NotificationFeedDto { GeneratedAt = now, - Sections = NotificationFeedOrdering.Order(sections) + Sections = NotificationFeedOrdering.Order(sections), + SlaAtRisk = slaAtRisk }; } - // Dispatchers see their own work orders. Roles that already see every dispatcher's - // work on the dashboard see the whole account, including work nobody owns yet. - private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user) + // Reactive/Emergency work orders against their SEV response window. An at-risk work order is a + // normal dismissable row (and feeds the banner); a missed deadline is an acknowledge row that stays + // until someone acknowledges it. Breaches and at-risk rows are capped separately so a backlog of + // old breaches can never hide a work order that can still be saved. + private async Task<((string, int, IReadOnlyList) Section, IReadOnlyList AtRisk)> SlaAsync( + NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken) { - if (user.IsInRole("Dispatcher")) - { - var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier); - if (string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); - return (dispatcherId, false); - } + var candidates = await _data.GetSlaCandidatesAsync( + scope, + SlaCutoffs(level => SlaResponseWindows.AtRiskCreatedAtOrBefore(level, now)), + SlaCutoffs(level => SlaResponseWindows.BreachedCreatedAtOrBefore(level, now)), + SectionItemLimit, + cancellationToken); - if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler")) - return (null, true); + var atRisk = candidates.AtRisk + .Select(candidate => (Candidate: candidate, Clock: SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now))) + .Where(entry => entry.Clock is { State: SlaState.AtRisk }) + .Select(entry => new NotificationSlaWorkOrderDto + { + Id = entry.Candidate.Id, + Number = entry.Candidate.Number, + Severity = entry.Clock!.Severity, + StartedAt = entry.Clock.StartedAt, + DeadlineAt = entry.Clock.DeadlineAt, + PercentElapsed = entry.Clock.PercentElapsed + }) + .ToList(); - throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role."); + var items = candidates.Breached + .Select(candidate => SlaItem(candidate, SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now))) + .OfType() + .Concat(atRisk + .OrderByDescending(workOrder => workOrder.PercentElapsed) + .Take(SectionItemLimit) + .Select(workOrder => new NotificationItemDto + { + Id = $"sla-at-risk-{workOrder.Id}", + Reason = NotificationReasons.Sla, + Severity = NotificationSeverities.High, + Title = $"{WorkOrderLabel(workOrder.Id, workOrder.Number)} is at risk of missing its response deadline", + Count = 1, + TriggeredAt = workOrder.StartedAt + SlaResponseWindows.Respond[workOrder.Severity] / 2, + Target = WorkOrderTarget(workOrder.Id, NotificationWorkOrderTabs.Info), + WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = workOrder.Id, Number = workOrder.Number } } + })) + .ToList(); + + return ((NotificationReasons.Sla, candidates.BreachedTotal + atRisk.Count, items), atRisk); } + private static NotificationItemDto? SlaItem(NotificationSlaCandidate candidate, SlaClock? clock) + => clock is not { State: SlaState.Breached } + ? null + : new NotificationItemDto + { + Id = $"sla-breach-{candidate.Id}", + Reason = NotificationReasons.Sla, + Severity = NotificationSeverities.Critical, + RowType = NotificationRowTypes.Acknowledge, + Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} missed its response deadline", + Count = 1, + TriggeredAt = clock.DeadlineAt, + Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info), + WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } } + }; + + private static NotificationSlaCutoffs SlaCutoffs(Func cutoff) + => new(cutoff(1), cutoff(2), cutoff(3), cutoff(4), cutoff(5)); + // Items addressed to the signed-in user whatever their role: assignments to them, comments on // work they take part in, mentions of them, and decisions on uplifts they requested. The // account scope still applies; the dispatcher scope does not, because a mention or an uplift diff --git a/SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs b/SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs new file mode 100644 index 0000000..412317e --- /dev/null +++ b/SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class SlaBreachAcknowledgementService : ISlaBreachAcknowledgementService + { + private readonly INotificationFeedDataService _feedData; + private readonly IWorkOrderAuditDataService _auditData; + private readonly IUserDataService _userData; + private readonly IWorkOrderAccountResolver _accountResolver; + private readonly TimeProvider _timeProvider; + + public SlaBreachAcknowledgementService( + INotificationFeedDataService feedData, + IWorkOrderAuditDataService auditData, + IUserDataService userData, + IWorkOrderAccountResolver accountResolver, + TimeProvider timeProvider) + { + _feedData = feedData; + _auditData = auditData; + _userData = userData; + _accountResolver = accountResolver; + _timeProvider = timeProvider; + } + + public async Task AcknowledgeAsync( + ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken) + { + // The same audience as the feed: a row the caller cannot see cannot be acknowledged. + var accountId = _accountResolver.ResolveAccountFilter(user); + var (dispatcherId, _) = NotificationAudience.Resolve(user); + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + throw new WorkOrderBoardValidationException("Forbidden", "Acknowledging requires a signed-in user."); + + var candidate = await _feedData.GetSlaCandidateAsync( + new NotificationFeedScope(accountId, dispatcherId), workOrderId, cancellationToken); + if (candidate == null) + return SlaBreachAcknowledgementOutcome.NotFound; + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var clock = SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now); + if (clock is not { State: SlaState.Breached }) + return SlaBreachAcknowledgementOutcome.NotBreached; + if (candidate.BreachAcknowledged) + return SlaBreachAcknowledgementOutcome.AlreadyAcknowledged; + + var names = await _userData.GetDisplayNamesByIdsAsync(new[] { userId }); + var name = names.TryGetValue(userId, out var displayName) && !string.IsNullOrWhiteSpace(displayName) + ? displayName + : user.FindFirstValue(ClaimTypes.Name) ?? userId; + + _auditData.EnqueueAuditLog(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + UserId = userId, + FieldName = SlaBreachAcknowledgementAudit.FieldName, + OldValue = clock.DeadlineAt.ToString("o"), + NewValue = candidate.Severity, + Action = $"SLA breach acknowledged by {name}", + EventType = "system", + ActorType = "internal", + CreatedAt = now + }); + await _auditData.PersistAsync(cancellationToken); + return SlaBreachAcknowledgementOutcome.Acknowledged; + } + } +} diff --git a/SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs b/SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs new file mode 100644 index 0000000..e728a6c --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +namespace SeaHaven.Services.Interfaces +{ + public enum SlaBreachAcknowledgementOutcome + { + Acknowledged, + /// This breach was already acknowledged; nothing new is recorded. + AlreadyAcknowledged, + /// Not an SLA work order the caller's Notification Center covers (another account or dispatcher, closed, no SEV level). + NotFound, + /// The work order has not missed its response deadline. + NotBreached + } + + /// + /// Acknowledging a missed SEV response deadline from the Notification Center: records who and when in + /// the work order's audit history, which is also what removes the breach row from the feed. + /// + public interface ISlaBreachAcknowledgementService + { + Task AcknowledgeAsync( + ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken); + } +} From c3865e56ac87bd0a088c2d0b447b4a425a081382 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:19:29 -0300 Subject: [PATCH 05/16] Sites API: site code uniqueness, soft delete with role check, open work orders, site notes - Reject duplicate site codes per client (case-insensitive); site code is immutable once set - Delete tombstones the site and requires the DeleteSites permission (Admin, Scheduler) - GET /api/locations/{id}/open-work-orders returns the open count and ids - PATCH /api/locations/{id}/contact-info saves contacts and notes from the work-order Site dialog - Add nullable Locations.Notes, used as the site-level POC notes fallback --- .../LocationControllerSitesTests.cs | 4 +- .../LocationControllerTests.cs | 76 + .../LocationServiceTests.cs | 36 +- .../LocationSiteContactsTests.cs | 84 +- .../SiteRegistryServiceTests.cs | 449 ++ .../Controllers/LocationController.cs | 80 +- .../DTOs/EditLocation_DTO.cs | 1 + Api.SeaHavenIndustries/DTOs/Location_DTO.cs | 8 + ...0260925141509_AddLocationNotes.Designer.cs | 4216 +++++++++++++++++ .../20260925141509_AddLocationNotes.cs | 28 + .../ApplicationDbContextModelSnapshot.cs | 3 + Data.SeaHavenIndustries/Models/Locations.cs | 3 + .../Helpers/WorkOrderBoardProjection.cs | 3 +- .../Implementation/CommonDataService.cs | 1 + .../Implementation/LocationDataService.cs | 92 +- .../Interfaces/ILocationDataService.cs | 27 +- SeaHaven.Services/DTOs/LocationDTOs.cs | 19 + .../Exceptions/SiteExceptions.cs | 24 + .../WorkOrderCompletionSnapshotMapper.cs | 2 +- .../Implementation/LocationService.cs | 184 +- .../Interfaces/ILocationService.cs | 4 +- 21 files changed, 5207 insertions(+), 137 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.cs create mode 100644 SeaHaven.Services/Exceptions/SiteExceptions.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs index b926519..ebd3a74 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -31,7 +31,9 @@ public class LocationControllerSitesTests dataService, new AccountDataService(ctx), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); var controller = new LocationController(service, Mock.Of>()) { diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index 888f092..a1dc2c3 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -286,4 +286,80 @@ public class LocationControllerTests contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane"); contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford"); } + + [Fact] + public async Task AddLocation_DuplicateSiteCode_Returns409WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException()); + + var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + Prop(conflict.Value!, "Code").Should().Be("DuplicateSiteCode"); + Prop(conflict.Value!, "Message").Should().Be("This site code already exists."); + } + + [Fact] + public async Task EditLocation_DuplicateSiteCode_Returns409() + { + var service = new Mock(); + service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException()); + + var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage() + { + var service = new Mock(); + service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny(), It.IsAny())) + .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteForbiddenException( + SeaHaven.Services.Exceptions.SiteForbiddenException.DeleteDeniedMessage)); + + var result = await NewController(service).DeleteLocation(4, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(403); + forbidden.Value.Should().BeOfType().Which.Message.Should().Be("You are not allowed to delete sites."); + } + + [Fact] + public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404() + { + var service = new Mock(); + service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny(), It.IsAny())) + .ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } }); + + var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None)) + .Should().BeOfType().Subject; + ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } }); + + (await NewController(service).GetOpenWorkOrders(5, CancellationToken.None)) + .Should().BeOfType(); + } + + [Fact] + public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService() + { + var service = new Mock(); + SiteContactInfoRequestDTO? seen = null; + service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, request, _, _) => seen = request) + .Returns(Task.CompletedTask); + + var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO + { + Contacts = new List { new() { Id = 7, Name = "Main", Phone = "555-0100" } }, + Notes = "Gate 4" + }, CancellationToken.None); + + result.Should().BeOfType(); + seen!.Notes.Should().Be("Gate 4"); + seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" }); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 2c986c8..dbf05cb 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -28,7 +28,9 @@ public class LocationServiceTests new LocationDataService(ctx), new AccountDataService(ctx), new CreateLocationValidation(), - new UpdateLocationValidation()); + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer") { @@ -211,7 +213,9 @@ public class LocationServiceTests dataService, Mock.Of(), new CreateLocationValidation(), - new UpdateLocationValidation()); + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); [Fact] public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull() @@ -237,14 +241,14 @@ public class LocationServiceTests await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO { - Name = "New", + Name = "Old", Address = "9 New St", City = "Plano", Status = "Inactive" }, OrgWideAdmin(), CancellationToken.None); var row = ctx.Locations.Single(); - row.Name.Should().Be("New"); + row.Name.Should().Be("Old", "the site code is immutable"); row.Address1.Should().Be("9 New St"); row.City.Should().Be("Plano"); row.Status.Should().Be("Inactive"); @@ -263,7 +267,7 @@ public class LocationServiceTests await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO { - Name = "New", + Name = "Old", City = "Plano" }, OrgWideAdmin(), CancellationToken.None); @@ -301,12 +305,12 @@ public class LocationServiceTests await NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, - new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" }, + new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" }, AccountUser(4), CancellationToken.None); var row = ctx.Locations.Single(); - row.Name.Should().Be("Renamed"); + row.Name.Should().Be("Owned"); row.City.Should().Be("Austin"); row.AccountId.Should().Be(4); } @@ -476,7 +480,9 @@ public class LocationServiceTests new LocationDataService(ctx), accounts.Object, new CreateLocationValidation(), - new UpdateLocationValidation()); + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); using var cts = new CancellationTokenSource(); @@ -520,20 +526,6 @@ public class LocationServiceTests ctx.Locations.Single().AccountId.Should().Be(4); } - [Fact] - public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing() - { - using var ctx = NewContext(); - var existing = SeedLocation(ctx, "Gone", "Cedar Park"); - - var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None); - var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None); - - removed.Should().BeTrue(); - again.Should().BeFalse(); - ctx.Locations.Should().BeEmpty(); - } - [Fact] public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService() { diff --git a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs index f4aef07..5e64489 100644 --- a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs @@ -35,7 +35,9 @@ public class LocationSiteContactsTests new LocationDataService(ctx), new AccountDataService(ctx), new CreateLocationValidation(), - new UpdateLocationValidation()); + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); private static ClaimsPrincipal OrgWideAdmin() { @@ -54,6 +56,7 @@ public class LocationSiteContactsTests await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO { Name = "Depot", + Phone = "555-9000", AccountId = null, Contacts = new List { @@ -65,7 +68,7 @@ public class LocationSiteContactsTests } [Fact] - public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_SetsAccountFromLocation() + public async Task Create_WithContacts_PersistsTrimmedOrderedRows_KeepsSitePhoneIndependent_SetsAccountFromLocation() { using var ctx = NewContext(); ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false }); @@ -83,7 +86,7 @@ public class LocationSiteContactsTests }, OrgWideAdmin(), CancellationToken.None); var location = ctx.Locations.Include(l => l.Contacts).Single(); - location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber"); + location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts"); var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList(); contacts.Should().HaveCount(2); @@ -220,7 +223,7 @@ public class LocationSiteContactsTests } [Fact] - public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror() + public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest() { using var ctx = NewContext(); var seeded = await SeedLocationWithContactsAsync(ctx); @@ -239,7 +242,7 @@ public class LocationSiteContactsTests }, OrgWideAdmin(), CancellationToken.None); var location = ctx.Locations.Include(l => l.Contacts).Single(); - location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact"); + location.PhoneNumber.Should().BeNull("Site Phone comes from the request, not the first contact"); var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList(); active.Should().HaveCount(3); @@ -304,7 +307,7 @@ public class LocationSiteContactsTests after.Should().HaveCount(snapshot.Count); after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location)); ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber - .Should().Be("555-0100", "the rejected update must not persist any change"); + .Should().Be("555-9000", "the rejected update must not persist any change"); } [Fact] @@ -448,7 +451,9 @@ public class LocationSiteContactsTests data.Object, Mock.Of(), new CreateLocationValidation(), - new UpdateLocationValidation()); + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None); @@ -489,67 +494,6 @@ public class LocationSiteContactsTests rows.Should().BeEmpty(); } - [Fact] - public async Task DataService_DeleteByIdAsync_WithContacts_RemovesLocation_RetainsSoftDeletedDetachedContacts() - { - using var ctx = NewContext(); - var seeded = await SeedLocationWithContactsAsync(ctx); - var alice = seeded.Contacts!.Single(c => c.FirstName == "Alice Cooper"); - var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon"); - - var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None); - - deleted.Should().BeTrue(); - ctx.Locations.Should().BeEmpty(); - - var retained = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList(); - retained.Should().HaveCount(2); - retained.Should().OnlyContain(c => c.IsDeleted == true); - retained.Should().OnlyContain(c => c.LocationId == null); - retained.Should().OnlyContain(c => c.DeletionTime != null); - retained.Should().OnlyContain(c => c.DeleterUserId == null, "the delete interface carries no actor"); - retained.Single(c => c.Id == alice.Id).FirstName.Should().Be("Alice Cooper"); - retained.Single(c => c.Id == alice.Id).PhoneNumber.Should().Be("555-0100"); - retained.Single(c => c.Id == bob.Id).FirstName.Should().Be("Bob Dillon"); - retained.Single(c => c.Id == bob.Id).PhoneNumber.Should().Be("555-0200"); - } - - [Fact] - public async Task DataService_DeleteByIdAsync_AlreadySoftDeletedContact_IsDetachedWithoutAuditRestamp() - { - using var ctx = NewContext(); - var seeded = await SeedLocationWithContactsAsync(ctx); - var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon"); - bob.IsDeleted = true; - bob.DeleterUserId = "admin-1"; - bob.DeletionTime = new DateTime(2026, 1, 1); - await ctx.SaveChangesAsync(); - var bobStamp = bob.DeletionTime; - - var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None); - - deleted.Should().BeTrue(); - ctx.Locations.Should().BeEmpty(); - var retained = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id); - retained.LocationId.Should().BeNull("previously soft-deleted rows must also detach or the restrict FK blocks the delete"); - retained.IsDeleted.Should().BeTrue(); - retained.DeleterUserId.Should().Be("admin-1", "original audit stamp is preserved"); - retained.DeletionTime.Should().Be(bobStamp); - } - - [Fact] - public async Task DataService_DeleteByIdAsync_Missing_ReturnsFalse() - { - using var ctx = NewContext(); - await SeedLocationWithContactsAsync(ctx); - - var deleted = await new LocationDataService(ctx).DeleteByIdAsync(424242, CancellationToken.None); - - deleted.Should().BeFalse(); - ctx.Locations.Should().HaveCount(1); - ctx.Contacts.Should().HaveCount(2); - } - private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts { public void UpExposed(MigrationBuilder builder) => Up(builder); @@ -608,7 +552,7 @@ public class LocationSiteContactsTests using var json = JsonSerializer.SerializeToDocument(ok.Value); var root = json.RootElement; - root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact"); + root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts"); root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name"); var contacts = root.GetProperty("Contacts"); contacts.GetArrayLength().Should().Be(2); @@ -636,7 +580,7 @@ public class LocationSiteContactsTests var row = json.RootElement.GetProperty("Data").EnumerateArray().Single(); row.GetProperty("Contact").GetString().Should().Be("Alice Cooper"); - row.GetProperty("Phone").GetString().Should().Be("555-0100"); + row.GetProperty("Phone").GetString().Should().Be("555-9000"); row.GetProperty("Contacts").GetArrayLength().Should().Be(2); } } diff --git a/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs new file mode 100644 index 0000000..5e8c18c --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs @@ -0,0 +1,449 @@ +using System.Security.Claims; +using System.Text.Json; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Site registry rules: tenant-scoped unique site codes, immutable codes, +/// permission-gated tombstone delete, open work order lookup and the +/// contact/notes write used by the work-order Site dialog. +/// +public class SiteRegistryServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationService NewService(ApplicationDbContext ctx, string? role = "Admin") => + NewService(new LocationDataService(ctx), new AccountDataService(ctx), role); + + private static LocationService NewService( + ILocationDataService data, + IAccountDataService accounts, + string? role = "Admin") + { + var permissions = new Mock(); + permissions + .Setup(p => p.GetUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string userId, CancellationToken _) => role == null + ? null + : new TeamPermissionUserData { UserId = userId, RoleName = role }); + + return new LocationService( + data, + accounts, + new CreateLocationValidation(), + new UpdateLocationValidation(), + permissions.Object, + new TeamPermissionPolicy()); + } + + private static ClaimsPrincipal OrgWide(string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)); + + private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin") => + Principal(role, new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString())); + + private static ClaimsPrincipal Principal(string role, Claim scope) => + new(new ClaimsIdentity(new List + { + new(ClaimTypes.NameIdentifier, "actor-1"), + new(ClaimTypes.Role, role), + scope + }, "test")); + + private static void SeedAccounts(ApplicationDbContext ctx, params int[] ids) + { + foreach (var id in ids) + ctx.Accounts.Add(new Accounts { Id = id, Name = $"Client {id}", IsDeleted = false }); + ctx.SaveChanges(); + } + + private static Locations SeedSite(ApplicationDbContext ctx, string code, int? accountId, bool deleted = false) + { + var site = new Locations { Name = code, AccountId = accountId, City = "Dallas", State = "TX", IsDeleted = deleted ? true : null }; + ctx.Locations.Add(site); + ctx.SaveChanges(); + return site; + } + + private static WorkOrder Wo( + int id, + int? locationId, + LifecycleStatus? status = LifecycleStatus.Incomplete, + int? accountId = 1, + string? siteCode = null, + string? legacyStatus = null, + bool deleted = false) => new() + { + Id = id, + LocationId = locationId, + LifecycleStatus = status, + LegacyStatus = legacyStatus, + AccountId = accountId, + SiteCode = siteCode, + IsDeleted = deleted ? true : null + }; + + private static LocationCreateRequestDTO CreateRequest(string code, int? accountId) => new() + { + Name = code, + AccountId = accountId, + Address = "1 Depot Rd", + City = "Dallas", + State = "TX", + Contacts = new List { new() { Name = "Main", Phone = "555-0100" } } + }; + + [Fact] + public async Task Create_DuplicateSiteCodeInSameClient_IsRejectedCaseInsensitively() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + SeedSite(ctx, "BK5", 1); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" bk5 ", 1), OrgWide(), CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Should().ContainSingle(); + } + + [Fact] + public async Task Create_SameSiteCodeForAnotherClient_IsAllowed() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1, 2); + SeedSite(ctx, "BK5", 1); + + await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 2), OrgWide(), CancellationToken.None); + + ctx.Locations.Where(l => l.Name == "BK5").Select(l => l.AccountId).Should().BeEquivalentTo(new int?[] { 1, 2 }); + } + + [Fact] + public async Task Create_SiteCodeOfADeletedSite_CanBeReused() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + SeedSite(ctx, "BK5", 1, deleted: true); + + await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), CancellationToken.None); + + ctx.Locations.Count(l => l.Name == "BK5" && l.IsDeleted != true).Should().Be(1); + } + + [Fact] + public async Task Create_BlankSiteCode_IsAValidationError() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" ", 1), OrgWide(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code is required."); + } + + [Fact] + public async Task Create_StoresTrimmedCodeNotesAndSitePhoneIndependentOfContacts() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + var request = CreateRequest(" DFW8 ", 1); + request.Phone = "555-9000"; + request.Notes = " Gate code 4411 "; + + await NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None); + + var site = ctx.Locations.Single(); + site.Name.Should().Be("DFW8"); + site.PhoneNumber.Should().Be("555-9000"); + site.Notes.Should().Be("Gate code 4411"); + } + + [Fact] + public async Task Update_ChangingAnExistingSiteCode_IsRejected() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync( + site.Id, new LocationUpdateRequestDTO { Name = "BK6" }, OrgWide(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code cannot be changed."); + ctx.Locations.AsNoTracking().Single().Name.Should().Be("BK5"); + } + + [Fact] + public async Task Update_KeepsCodeAndNotesWhenTheRequestOmitsThem() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + site.Notes = "Dock 3"; + site.Status = "Active"; + ctx.SaveChanges(); + + await NewService(ctx).UpdateLocationFromRequestAsync( + site.Id, new LocationUpdateRequestDTO { Name = "bk5", City = "Memphis" }, OrgWide(), CancellationToken.None); + + var saved = ctx.Locations.AsNoTracking().Single(); + saved.Name.Should().Be("BK5"); + saved.City.Should().Be("Memphis"); + saved.Notes.Should().Be("Dock 3"); + saved.Status.Should().Be("Active"); + } + + [Fact] + public async Task Update_BlankLegacyCode_CanBeFilledOnceButMustBeUniqueInTheClient() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + SeedSite(ctx, "BK5", 1); + var legacy = SeedSite(ctx, "", 1); + + var duplicate = () => NewService(ctx).UpdateLocationFromRequestAsync( + legacy.Id, new LocationUpdateRequestDTO { Name = "bk5" }, OrgWide(), CancellationToken.None); + await duplicate.Should().ThrowAsync(); + + await NewService(ctx).UpdateLocationFromRequestAsync( + legacy.Id, new LocationUpdateRequestDTO { Name = "MEM1" }, OrgWide(), CancellationToken.None); + ctx.Locations.AsNoTracking().Single(l => l.Id == legacy.Id).Name.Should().Be("MEM1"); + } + + [Theory] + [InlineData("Admin")] + [InlineData("Scheduler")] + public async Task Delete_AdminOrScheduler_TombstonesTheSiteAndLeavesWorkOrdersAsTheyWere(string role) + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + var site = SeedSite(ctx, "BK5", 1); + ctx.Contacts.Add(new Contacts { LocationId = site.Id, FirstName = "Main", PhoneNumber = "555-0100" }); + ctx.workOrders.Add(Wo(10, site.Id)); + ctx.SaveChanges(); + + var deleted = await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None); + + deleted.Should().BeTrue(); + var tombstone = ctx.Locations.AsNoTracking().Single(); + tombstone.IsDeleted.Should().BeTrue(); + tombstone.DeleterUserId.Should().Be("actor-1"); + ctx.workOrders.AsNoTracking().Single().LocationId.Should().Be(site.Id, "work orders keep their site reference"); + ctx.Contacts.AsNoTracking().Single().IsDeleted.Should().NotBe(true, "contacts still back open work orders"); + + var data = new LocationDataService(ctx); + (await data.GetDetailByIdAsync(site.Id, CancellationToken.None)).Should().BeNull(); + (await data.GetSiteOptionsAsync(null, CancellationToken.None)).Should().BeEmpty(); + (await data.GetListPagedAsync(1, 10, null, null, CancellationToken.None)).TotalCount.Should().Be(0); + (await data.GetAccountScopeAsync(site.Id, CancellationToken.None)).Exists.Should().BeFalse("a deleted site cannot be assigned to new work orders"); + (await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None)).Should().BeFalse(); + } + + [Theory] + [InlineData("Dispatcher")] + [InlineData(null)] + public async Task Delete_WithoutDeleteSitesPermission_IsForbiddenAndKeepsTheSite(string? role) + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + + var act = () => NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role ?? "Dispatcher"), CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true); + } + + [Fact] + public async Task Delete_SiteOfAnotherClient_IsRejectedForAnAccountScopedCaller() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 2); + + var act = () => NewService(ctx).DeleteLocationByIdAsync(site.Id, AccountUser(1), CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true); + } + + [Fact] + public async Task OpenWorkOrders_ExcludeTerminalDeletedAndOtherSitesWork() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 1); + var other = SeedSite(ctx, "MEM1", 1); + ctx.workOrders.AddRange( + Wo(1, site.Id), + Wo(2, site.Id, LifecycleStatus.Scheduled), + Wo(3, site.Id, LifecycleStatus.Completed), + Wo(4, site.Id, LifecycleStatus.Canceled), + Wo(5, site.Id, deleted: true), + Wo(6, site.Id, status: null, legacyStatus: "Completed"), + Wo(7, site.Id, status: null, legacyStatus: "Open"), + Wo(8, null, siteCode: "bk5"), + Wo(9, null, siteCode: "BK5", accountId: 2), + Wo(10, other.Id)); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None); + + result!.WorkOrderIds.Should().Equal(1, 2, 7, 8); + result.Count.Should().Be(4); + } + + [Fact] + public async Task OpenWorkOrders_AccountScopedCaller_SeesOnlyTheirClientsWork() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 1); + ctx.workOrders.AddRange(Wo(1, site.Id, accountId: 1), Wo(2, site.Id, accountId: 2)); + ctx.SaveChanges(); + + var own = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(1), CancellationToken.None); + own!.WorkOrderIds.Should().Equal(1); + + var foreign = () => NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(2), CancellationToken.None); + await foreign.Should().ThrowAsync(); + } + + [Fact] + public async Task OpenWorkOrders_ReturnFullCountButCapIds() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 1); + ctx.workOrders.AddRange(Enumerable.Range(1, LocationService.MaxOpenWorkOrderIds + 5).Select(id => Wo(id, site.Id))); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None); + + result!.Count.Should().Be(LocationService.MaxOpenWorkOrderIds + 5); + result.WorkOrderIds.Should().HaveCount(LocationService.MaxOpenWorkOrderIds); + } + + [Fact] + public async Task OpenWorkOrders_MissingOrDeletedSite_ReturnsNull() + { + using var ctx = NewContext(); + var deleted = SeedSite(ctx, "BK5", 1, deleted: true); + + (await NewService(ctx).GetOpenWorkOrdersAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeNull(); + (await NewService(ctx).GetOpenWorkOrdersAsync(999, OrgWide(), CancellationToken.None)).Should().BeNull(); + } + + [Fact] + public async Task UpdateSiteContactInfo_SavesContactsAndNotesToTheSiteRecord() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + var main = new Contacts { LocationId = site.Id, FirstName = "Old Main", PhoneNumber = "555-0100", SiteContactOrder = 0 }; + ctx.Contacts.Add(main); + ctx.SaveChanges(); + + await NewService(ctx).UpdateSiteContactInfoAsync(site.Id, new SiteContactInfoRequestDTO + { + Contacts = new List + { + new() { Id = main.Id, Name = "New Main", Phone = "555-0199" }, + new() { Name = "Night Shift", Phone = "555-0200" } + }, + Notes = " Call ahead " + }, OrgWide("Dispatcher"), CancellationToken.None); + + var saved = ctx.Locations.AsNoTracking().Include(l => l.Contacts).Single(); + saved.Notes.Should().Be("Call ahead"); + saved.Contacts!.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder) + .Select(c => (c.Id == main.Id, c.FirstName, c.PhoneNumber)) + .Should().Equal((true, "New Main", "555-0199"), (false, "Night Shift", "555-0200")); + } + + [Fact] + public async Task UpdateSiteContactInfo_RejectsOutOfScopeDeletedAndContactlessRequests() + { + using var ctx = NewContext(); + var foreign = SeedSite(ctx, "BK5", 2); + var deleted = SeedSite(ctx, "MEM1", 1, deleted: true); + var request = new SiteContactInfoRequestDTO + { + Contacts = new List { new() { Name = "A", Phone = "1" } } + }; + + await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None))) + .Should().ThrowAsync(); + await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None))) + .Should().ThrowAsync(); + await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync( + foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None))) + .Should().ThrowAsync(); + } + + [Fact] + public async Task NewSiteOperations_ForwardTheCallersCancellationToken() + { + using var cts = new CancellationTokenSource(); + var token = cts.Token; + var site = new Locations { Id = 5, Name = "BK5", AccountId = 1, Contacts = new List() }; + var data = new Mock(); + data.Setup(d => d.GetByIdForUpdateAsync(5, token)).ReturnsAsync(site); + data.Setup(d => d.GetDetailByIdAsync(5, token)).ReturnsAsync(site); + data.Setup(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token)) + .ReturnsAsync((1, new[] { 7 })); + var service = NewService(data.Object, Mock.Of()); + + await service.GetOpenWorkOrdersAsync(5, OrgWide(), token); + await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO + { + Contacts = new List { new() { Name = "A", Phone = "1" } } + }, OrgWide(), token); + await service.DeleteLocationByIdAsync(5, OrgWide(), token); + + data.Verify(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token), Times.Once); + data.Verify(d => d.UpdateAsync(site, token), Times.Exactly(2)); + } + + [Fact] + public async Task Create_DuplicateCheck_ForwardsTheCallersCancellationToken() + { + using var cts = new CancellationTokenSource(); + var token = cts.Token; + var data = new Mock(); + data.Setup(d => d.SiteCodeExistsAsync("BK5", null, null, token)).ReturnsAsync(true); + var service = NewService(data.Object, Mock.Of()); + + var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", null), OrgWide(), token); + + await act.Should().ThrowAsync(); + data.Verify(d => d.SiteCodeExistsAsync("BK5", null, null, token), Times.Once); + } + + [Fact] + public void CompletionSnapshot_FreezesSiteNotesWhenTheWorkOrderHasNone() + { + var workOrder = new WorkOrder + { + Id = 1, + Locations = new Locations { Id = 5, Name = "BK5", Notes = "Gate code 4411", Contacts = new List() } + }; + + WorkOrderCompletionSnapshotMapper.Capture(workOrder); + + using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!); + frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411"); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index a956819..84270e6 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -105,6 +106,9 @@ namespace Api.SeaHavenIndustries.Controllers ContactEmail = location.Email, location.Status, location.AccountId, + ClientName = location.AccountName, + location.AccountName, + location.Notes, Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList() }; @@ -119,6 +123,10 @@ namespace Api.SeaHavenIndustries.Controllers await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken); return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" }); } + catch (SiteCodeConflictException) + { + return SiteCodeConflict(); + } catch (ValidationException vex) { var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); @@ -142,6 +150,10 @@ namespace Api.SeaHavenIndustries.Controllers await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken); return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" }); } + catch (SiteCodeConflictException) + { + return SiteCodeConflict(); + } catch (ValidationException vex) { var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); @@ -161,17 +173,73 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPatch("{id}/contact-info")] + public async Task UpdateSiteContactInfo(int id, [FromBody] SiteContactInfoInput_DTO model, CancellationToken cancellationToken) + { + try + { + await _locationService.UpdateSiteContactInfoAsync( + id, + new SiteContactInfoRequestDTO { Contacts = MapSiteContacts(model.Contacts), Notes = model.Notes }, + User, + cancellationToken); + return Ok(new DataResponse { Message = "Site Updated Successfully", Status = "200" }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to edit this site." }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpGet("{id}/open-work-orders")] + public async Task GetOpenWorkOrders(int id, CancellationToken cancellationToken) + { + try + { + var result = await _locationService.GetOpenWorkOrdersAsync(id, User, cancellationToken); + if (result == null) + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + + return Ok(result); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to view this site." }); + } + } + [HttpDelete("{id}")] public async Task DeleteLocation(int id, CancellationToken cancellationToken) { try { - var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken); + var found = await _locationService.DeleteLocationByIdAsync(id, User, cancellationToken); if (!found) return NotFound(new Response { Status = "Error", Message = "Location not found" }); return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" }); } + catch (SiteForbiddenException forbidden) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = forbidden.Message }); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to delete this site." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Location not found" }); @@ -188,6 +256,14 @@ namespace Api.SeaHavenIndustries.Controllers return await DeleteLocation(id, cancellationToken); } + private ObjectResult SiteCodeConflict() => + Conflict(new + { + Status = "Conflict", + Message = SiteCodeConflictException.PublicMessage, + Code = SiteCodeConflictException.ErrorCode + }); + private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model) { return new LocationCreateRequestDTO @@ -202,6 +278,7 @@ namespace Api.SeaHavenIndustries.Controllers ContactEmail = model.ContactEmail, Status = model.Status, AccountId = model.GetAccountId(), + Notes = model.Notes, Contacts = MapSiteContacts(model.Contacts) }; } @@ -220,6 +297,7 @@ namespace Api.SeaHavenIndustries.Controllers ContactEmail = model.ContactEmail, Status = model.Status, AccountId = model.GetAccountId(), + Notes = model.Notes, Contacts = MapSiteContacts(model.Contacts) }; } diff --git a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs index 4a3cab8..b526bad 100644 --- a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs @@ -16,6 +16,7 @@ namespace Api.SeaHavenIndustries.DTOs public string? ContactEmail { get; set; } public string? Status { get; set; } public string? AccountId { get; set; } + public string? Notes { get; set; } public List? Contacts { get; set; } public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId); diff --git a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs index 343df36..4fd7f72 100644 --- a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs @@ -11,6 +11,13 @@ namespace Api.SeaHavenIndustries.DTOs public string? Phone { get; set; } } + /// Contacts and notes edited from the work-order Site dialog. + public class SiteContactInfoInput_DTO + { + public List? Contacts { get; set; } + public string? Notes { get; set; } + } + public class Location_DTO { public string? Title { get; set; } @@ -24,6 +31,7 @@ namespace Api.SeaHavenIndustries.DTOs public string? ContactEmail { get; set; } public string? Status { get; set; } public string? AccountId { get; set; } + public string? Notes { get; set; } public List? Contacts { get; set; } public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId); diff --git a/Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs new file mode 100644 index 0000000..53d31b2 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs @@ -0,0 +1,4216 @@ +// +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("20260925141509_AddLocationNotes")] + partial class AddLocationNotes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsAccountOwner") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingRegistration") + .HasColumnType("bit"); + + b.Property("PendingRegistrationCreatedDate") + .HasColumnType("datetime2"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("IsAccountOwner") + .IsUnique() + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner") + .HasFilter("IsAccountOwner = 1"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ServiceId"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany("Contacts") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("ServiceAreas"); + + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Contacts"); + + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.cs b/Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.cs new file mode 100644 index 0000000..8f437e5 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class AddLocationNotes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Notes", + table: "Locations", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Notes", + table: "Locations"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 4a3d3c9..e0660f5 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1582,6 +1582,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Name") .HasColumnType("nvarchar(max)"); + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + b.Property("PhoneNumber") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/Locations.cs b/Data.SeaHavenIndustries/Models/Locations.cs index ce8a6ba..9900c35 100644 --- a/Data.SeaHavenIndustries/Models/Locations.cs +++ b/Data.SeaHavenIndustries/Models/Locations.cs @@ -27,6 +27,9 @@ namespace Data.SeaHavenIndustries public string? ExternalSource { get; set; } public string? ExternalLocationId { get; set; } + /// Free-text site notes, edited from the work-order Site dialog. + public string? Notes { get; set; } + // Navigation Properties public ICollection