diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 1d4e8ec..074f36d 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -418,9 +418,14 @@ public class DashboardServiceTests } [Fact] - public async Task GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate() + public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() { using var ctx = NewContext(); + // ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work + // order buckets on its own calendar day regardless of the time-of-day component. The + // UTC hours below were previously shifted a full day by an America/New_York conversion + // (03:00 UTC -> prior evening), so they double as a regression guard: each row must now + // stay on the calendar date it was scheduled for. ctx.workOrders.AddRange( new WorkOrder { @@ -475,17 +480,21 @@ public class DashboardServiceTests trend.Buckets.Select(bucket => bucket.Label).Should().Equal( "2026-01-14", "2026-01-15", "2026-01-16"); - trend.Buckets[0].Total.Should().Be(2); - trend.Buckets[0].Open.Should().Be(1); + // 01-14: only the canceled row scheduled that calendar day. + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(0); trend.Buckets[0].Canceled.Should().Be(1); trend.Buckets[0].Completed.Should().Be(0); - trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(0); + // 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row. trend.Buckets[1].Total.Should().Be(2); trend.Buckets[1].Open.Should().Be(1); trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Canceled.Should().Be(0); trend.Buckets[1].Overdue.Should().Be(1); + // 01-16: the 03:00 UTC scheduled row that stays on its own day. trend.Buckets[2].Total.Should().Be(1); trend.Buckets[2].Open.Should().Be(1); trend.Buckets[2].Overdue.Should().Be(1); @@ -493,6 +502,38 @@ public class DashboardServiceTests trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); } + [Fact] + public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + // A board-scheduled work order for today is stored as midnight (`.Date`). It must land + // in the Today bucket and count as open, not roll back to yesterday and read as overdue. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-1), + DateTo = today.AddDays(1) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today); + todayBucket.Total.Should().Be(1); + todayBucket.Open.Should().Be(1); + todayBucket.Overdue.Should().Be(0); + + var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1)); + yesterdayBucket.Total.Should().Be(0); + } + [Fact] public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() { @@ -564,6 +605,8 @@ public class DashboardServiceTests LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) }, + // 2027-01-01 by calendar: outside the 2026 window. Previously an + // America/New_York conversion pulled it back to 2026-12-31 and into December. new WorkOrder { AccountId = 1, @@ -584,10 +627,10 @@ public class DashboardServiceTests trend.Buckets[2].Label.Should().Be("2026-03-01"); trend.Buckets[2].Total.Should().Be(1); trend.Buckets[2].Completed.Should().Be(1); + // December stays empty: the 2027-01-01 row is no longer pulled inside the window. trend.Buckets[11].Label.Should().Be("2026-12-01"); - trend.Buckets[11].Total.Should().Be(1); - trend.Buckets[11].Open.Should().Be(1); - trend.Buckets[11].Overdue.Should().Be(0); + trend.Buckets[11].Total.Should().Be(0); + trend.Buckets[11].Open.Should().Be(0); trend.Today.Should().Be(DashboardBusinessTime.Today()); } diff --git a/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs new file mode 100644 index 0000000..0f0861e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs @@ -0,0 +1,80 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class TeamPermissionOverrideDataServiceTests +{ + [Fact] + public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Dispatcher"); + context.UserPermissionOverrides.Add(new UserPermissionOverride + { + UserId = "u1", + PermissionKey = "deleteSites", + State = UserPermissionState.Deny + }); + await context.SaveChangesAsync(); + + var result = await new TeamPermissionOverrideDataService(context) + .GetUserAsync("u1", CancellationToken.None); + + result.Should().NotBeNull(); + result!.RoleName.Should().Be("Dispatcher"); + result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny); + } + + [Fact] + public async Task SetOverrideAsync_UpsertsOneCompositeKey() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Scheduler"); + await context.SaveChangesAsync(); + var service = new TeamPermissionOverrideDataService(context); + + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None); + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None); + + var rows = await context.UserPermissionOverrides.ToListAsync(); + rows.Should().ContainSingle(); + rows[0].State.Should().Be(UserPermissionState.Deny); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName) + { + context.Users.Add(new ApplicationUser + { + Id = userId, + UserName = userId, + NormalizedUserName = userId.ToUpperInvariant(), + Email = userId + "@example.com", + NormalizedEmail = (userId + "@example.com").ToUpperInvariant() + }); + context.Roles.Add(new IdentityRole + { + Id = "role-1", + Name = roleName, + NormalizedName = roleName.ToUpperInvariant() + }); + context.UserRoles.Add(new IdentityUserRole + { + UserId = userId, + RoleId = "role-1" + }); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs index b10a37d..e57a307 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs @@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_Returns200() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("At least one company phone or email is required.")); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None); @@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_DuplicateName_ReturnsStable409() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterDuplicateNameException( "database detail", new InvalidOperationException("IX_VendorCompanies_NormalizedName"))); @@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed.")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled } }; var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterConflictException("blocked", blocked)); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Patch_AddsTechnicians_Returns200WithRoster() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians.")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None); @@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); } + + [Fact] + public async Task AreaAssignmentForbidden_Returns403OnEveryMutation() + { + var service = new Mock(); + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + var controller = NewController(service); + + var results = new[] + { + await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None), + await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None), + await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None) + }; + + foreach (var result in results) + result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + + service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", controller.User, It.IsAny()), Times.Once); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs index 6114c31..cd7627c 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs @@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests await act.Should().ThrowAsync(); } + + [Fact] + public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName() + { + var dbName = Guid.NewGuid().ToString(); + using var context = NewContext(dbName); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 2, Name = "Central", NormalizedName = "central" }); + await context.SaveChangesAsync(); + var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech"); + var service = new VendorCompanyRosterDataService(context); + + VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new() + { + CompanyId = companyId, + Name = "Area Co", + Email = "area@example.com", + AreaIdProvided = provided, + AreaId = areaId, + Technicians = context.Vendors.Where(v => v.CompanyId == companyId) + .Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName }) + .ToList() + }; + + var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None); + assigned.AreaId.Should().Be(2); + assigned.AreaName.Should().Be("Central"); + + var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None); + untouched.AreaId.Should().Be(2); + + var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None); + cleared.AreaId.Should().BeNull(); + cleared.AreaName.Should().BeNull(); + + (await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue(); + (await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs index 5991fd5..72b29db 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Moq; +using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; @@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorCompanyRosterServiceTests { + private static readonly ClaimsPrincipal Dispatcher = Principal("User"); + private static readonly ClaimsPrincipal Admin = Principal("Admin"); + + private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) }, + "Test")); + private static VendorCompanyRosterService NewService(Mock data) => new(data.Object); private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new() @@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone)); @@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 7, ContactName = "Riley" } } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List() - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.Technicians.Should().BeEmpty(); @@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests GoogleMapsUrl = "http://maps.google.com/acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl)); @@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "Riley", Phone = "+1 312 555 0100" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100"); } @@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme Co", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests Technicians = null! }; - await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().BeEmpty(); } @@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "", Phone = "", PreferredContact = "Phone" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().ContainSingle(); captured.Technicians.Single().ContactName.Should().BeEmpty(); @@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 1, ContactName = "T" } } }; - var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); } @@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", cts.Token); + }, "42", Dispatcher, cts.Token); data.Verify(x => x.SaveRosterAsync(It.IsAny(), cts.Token), Times.Once); } @@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests public async Task AddTechnicians_WithoutAuthenticatedUser_Throws() { var data = new Mock(); - var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians)); @@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests AddTechnicians = new List { new() { ContactName = "T" } } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion)); @@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests { RowVersion = "AAAAAAAAD8I=", CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields!.Notes.Should().Be(notes); } @@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); // SH-250: every field is blank, so there is no company change to apply. Forwarding a // non-null write model made the data layer bump RowVersion and rewrite every @@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify( @@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields.Should().NotBeNull(); captured.CompanyFields!.City.Should().Be("Norfolk"); @@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests .ReturnsAsync(SampleReadModel(7, 1)); using var cts = new CancellationTokenSource(); - await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token); + await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), cts.Token), Times.Once); } + + private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId) + { + var model = SampleReadModel(7, 1); + model.AreaId = areaId; + return model; + } + + [Fact] + public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation() + { + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny())).ReturnsAsync(false); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId)); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningActiveArea_PersistsAreaId() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(2)); + + var result = await NewService(data).CreateRosterAsync( + new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 }, + "42", + Admin, + cts.Token); + + captured!.AreaId.Should().Be(2); + result.AreaId.Should().Be(2); + data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once); + } + + [Fact] + public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + + foreach (int? requested in new int?[] { 3, null }) + { + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = requested + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + var captured = new List(); + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured.Add(model)) + .ReturnsAsync(ReadModelWithArea(1)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com", + AreaId = 1 + }, "42", Dispatcher, CancellationToken.None); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com" + }, "42", Dispatcher, CancellationToken.None); + + captured.Should().HaveCount(2); + captured[0].AreaIdProvided.Should().BeTrue(); + captured[0].AreaId.Should().Be(1); + captured[1].AreaIdProvided.Should().BeFalse(); + } + + [Fact] + public async Task Reconcile_AdminClearsAreaToUnassigned() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(null)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = null + }, "42", Admin, CancellationToken.None); + + captured!.AreaIdProvided.Should().BeTrue(); + captured.AreaId.Should().BeNull(); + data.Verify(x => x.IsActiveAreaAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_NonAdminAreaChange_IsForbidden() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(null)); + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 } + }; + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull() + { + var omitted = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var cleared = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var assigned = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + + omitted.AreaIdProvided.Should().BeFalse(); + cleared.AreaIdProvided.Should().BeTrue(); + cleared.AreaId.Should().BeNull(); + assigned.AreaIdProvided.Should().BeTrue(); + assigned.AreaId.Should().Be(3); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index 99b7d1d..e0d34e0 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -72,6 +72,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), It.IsAny()), Times.Never); } @@ -155,6 +156,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), token)) .ReturnsAsync(new PagedResult { @@ -203,6 +205,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), token), Times.Once); service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( It.IsAny(), diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 7e7716a..4fc5ee1 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -470,12 +470,14 @@ public class VendorDataServiceTests search: "alice", trades: new[] { "Plumbing" }, locations: new[] { "Chicago, IL" }, - jobBuckets: new[] { "under-50" }).ToQueryString(); + jobBuckets: new[] { "under-50" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString(); sql.Should().Contain("GROUP BY"); sql.Should().Contain("LEFT JOIN"); sql.Should().Contain("TradeSpecialties"); sql.Should().Contain("TotalJobs"); + sql.Should().Contain("AreaId"); Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase) .Should().BeFalse("company totals must sum pre-aggregated scalar job counts"); } @@ -1044,4 +1046,69 @@ public class VendorDataServiceTests sql.Should().Contain("SELECT"); sql.Should().Contain("VendorCompanies"); } + + [Fact] + public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows() + { + await using var context = NewContext(); + var east = new Area { Id = 1, Name = "East", NormalizedName = "east" }; + var west = new Area { Id = 3, Name = "West", NormalizedName = "west" }; + context.Areas.AddRange(east, west); + var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east }; + var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west }; + var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" }; + var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east }; + context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly); + context.Vendors.AddRange( + new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false }, + new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false }, + new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var eastResult = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastResult.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only"); + eastResult.TotalCount.Should().Be(2); + var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co"); + eastRow.AreaId.Should().Be(east.Id); + eastRow.AreaName.Should().Be("East"); + eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East"); + + var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true }); + eastOrUnassigned.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co"); + eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull(); + + var eastAndBoston = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "Boston, MA" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co"); + + var matchesNobody = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter()); + matchesNobody.TotalCount.Should().Be(0); + matchesNobody.Items.Should().BeEmpty(); + + var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true); + unfiltered.TotalCount.Should().Be(5); + } + + [Fact] + public async Task GetActiveAreasAsync_ExcludesArchivedAreas() + { + await using var context = NewContext(); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false }); + await context.SaveChangesAsync(); + + var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None); + + areas.Select(area => area.Name).Should().Equal("East"); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index a8e996d..48351df 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -166,6 +166,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), token)) .ReturnsAsync((new[] { @@ -212,6 +213,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), token), Times.Once); } @@ -228,6 +230,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), It.IsAny())) .ReturnsAsync((new List(), 0)); @@ -756,6 +759,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); @@ -794,6 +799,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -822,6 +829,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -849,6 +858,8 @@ public class VendorServiceTests SortOrder = index + 1 }) .ToList()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, cts.Token); @@ -898,4 +909,96 @@ public class VendorServiceTests TradeCatalog.CanonicalTrades, opts => opts.WithStrictOrdering()); } + + private static Mock DirectoryDataWithAreas(Action capture) + { + var data = new Mock(); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 3, Name = "West", NormalizedName = "west" } + }); + data.Setup(x => x.GetCompanyDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, IReadOnlyCollection?, IReadOnlyCollection?, IReadOnlyCollection?, VendorAreaFilter?, CancellationToken>( + (_, _, _, _, _, _, _, _, filter, _) => capture(filter)) + .ReturnsAsync((new List(), 0)); + return data; + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().Equal(1); + captured.IncludeUnassigned.Should().BeTrue(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + // 4 is not in the active catalogue (unknown or archived); "West" is a label. + var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { "4", "West", "-3" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().BeEmpty(); + captured.IncludeUnassigned.Should().BeFalse(); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter() + { + VendorAreaFilter? captured = new(); + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" }); + + captured.Should().BeNull(); + data.Verify(x => x.GetActiveAreasAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(cts.Token)) + .ReturnsAsync(new List + { + new() { Id = 3, Name = "West", NormalizedName = "west" }, + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 4, Name = "california", NormalizedName = "california" } + }); + + var facets = await NewService(data).GetFacetsAsync(null, cts.Token); + + facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal( + (4, "california"), (1, "East"), (3, "West")); + data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once); + } } diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs new file mode 100644 index 0000000..2d88138 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs @@ -0,0 +1,106 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while +/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order, +/// and an unknown id keeps the existing "ID not found!" response. +/// +public class WorkOrderGetByIdBindingTests +{ + private const int ExistingId = 374; + private const int UnknownId = 999_999; + + private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService().ActionDescriptors.Items + .OfType() + .Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController)) + .Where(cad => cad.ActionConstraints! + .OfType() + .Any(c => c.HttpMethods.Contains("GET"))) + .Single(cad => string.Equals( + cad.AttributeRouteInfo?.Template?.Trim('/'), + $"api/WorkOrder/{relativeTemplate}", + StringComparison.Ordinal)); + } + + [Theory] + [InlineData("GetWorkorderById", "Query")] + [InlineData("{id:int}", "Path")] + public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource) + { + var action = ActionForTemplate(relativeTemplate); + + var id = action.Parameters.Single(p => p.Name == "id"); + id.BindingInfo.Should().NotBeNull(); + id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource); + } + + private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController() + { + var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" }; + var service = new Mock(); + service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny())).ReturnsAsync(existing); + service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny())) + .ReturnsAsync((WorkOrderDetailReadModel?)null); + + var controller = new WorkOrderController( + service.Object, + Mock.Of(), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + return (controller, existing); + } + + [Fact] + public async Task Existing_id_returns_the_same_work_order_through_both_routes() + { + var (controller, existing) = NewController(); + + var byQuery = await controller.GetWorkorderById(ExistingId); + var byRoute = await controller.GetWorkorderByRouteId(ExistingId); + + byQuery.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + byRoute.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + } + + [Fact] + public async Task Unknown_id_keeps_the_not_found_response_through_both_routes() + { + var (controller, _) = NewController(); + + foreach (var result in new[] + { + await controller.GetWorkorderById(UnknownId), + await controller.GetWorkorderByRouteId(UnknownId), + }) + { + var body = result.Should().BeOfType().Which.Value + .Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Be("ID not found!"); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 6411337..1bb17e5 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests; /// combination is registered more than once by different actions. /// /// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the -/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, -/// GetWorkorderById) and the two shared controller-level base routes. +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder) +/// and the two shared controller-level base routes. /// public class WorkOrderRouteContractTests { @@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes - /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). + /// come from 51 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { diff --git a/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs new file mode 100644 index 0000000..72704cc --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs @@ -0,0 +1,65 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +[Authorize] +[ApiController] +[Route("api/User/{userId}/Permissions")] +public sealed class TeamPermissionController : ControllerBase +{ + private readonly ITeamPermissionService _permissionService; + + public TeamPermissionController(ITeamPermissionService permissionService) + { + _permissionService = permissionService; + } + + [HttpGet] + public async Task GetProfile( + string userId, + CancellationToken cancellationToken) + { + var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken); + return ToActionResult(result); + } + + [HttpPut("{permissionKey}")] + public async Task SetOverride( + string userId, + string permissionKey, + [FromBody] SetTeamPermissionOverrideDTO request, + CancellationToken cancellationToken) + { + if (!Enum.IsDefined(request.State)) + return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." }); + + var result = await _permissionService.SetOverrideAsync( + userId, + permissionKey, + request.State, + User, + cancellationToken); + return ToActionResult(result); + } + + private static IActionResult ToActionResult( + TeamPermissionResult result) + { + return result.Status switch + { + TeamPermissionResultStatus.Success => new OkObjectResult(result.Value), + TeamPermissionResultStatus.Forbidden => new ForbidResult(), + TeamPermissionResultStatus.NotFound => new NotFoundObjectResult( + new Response { Status = "Error", Message = "User not found." }), + TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unknown permission key." }), + _ => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unable to update permissions." }) + }; + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs index fa5e4fa..1930fe9 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs @@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken); + var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (VendorRosterDuplicateNameException dup) { return Conflict(new @@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); @@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index fc42b88..2db1eba 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] string[]? trades = null, [FromQuery] string[]? locations = null, [FromQuery] string[]? jobBuckets = null, + [FromQuery] string[]? areas = null, CancellationToken cancellationToken = default) { var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync( @@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers trades, locations, jobBuckets, + areas, cancellationToken); var data = pagedResult.Items.Select(v => new @@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers v.Notes, v.IsActive, v.TotalJobs, + v.AreaId, + v.AreaName, Technicians = v.Technicians.Select(t => new { t.Id, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index c7928de..9c73034 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpGet("{id:int}")] + // SH-374: two actions, one per route, so each binds id from the source its route provides. + // A single action carrying both routes inferred id as [FromRoute] and the query route got 0. [HttpGet("GetWorkorderById")] - public async Task GetWorkorderById(int id) + public Task GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id); + + [HttpGet("{id:int}")] + public Task GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id); + + private async Task GetWorkorderDetail(int id) { try { diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a5256dc..f322ed8 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -206,6 +206,51 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(t => t.NormalizedName) .IsUnique(); + + // SH-278 Area catalogue: bounded names so the unique key is indexable. + builder.Entity() + .Property(a => a.Name) + .HasMaxLength(128); + + builder.Entity() + .Property(a => a.NormalizedName) + .HasMaxLength(128); + + builder.Entity() + .HasIndex(a => a.NormalizedName) + .IsUnique(); + + builder.Entity() + .HasOne(c => c.Area) + .WithMany() + .HasForeignKey(c => c.AreaId) + .OnDelete(DeleteBehavior.Restrict); + + // Per-person team permission overrides. Composite primary key + // (UserId + PermissionKey) plus an explicitly named unique composite + // index; missing rows behave as Unset. + builder.Entity(entity => + { + entity.HasKey(o => new { o.UserId, o.PermissionKey }); + + entity.Property(o => o.UserId) + .HasMaxLength(450); + + entity.Property(o => o.PermissionKey) + .HasMaxLength(64); + + entity.Property(o => o.State) + .IsRequired(); + + entity.HasOne(o => o.User) + .WithMany() + .HasForeignKey(o => o.UserId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasIndex(o => new { o.UserId, o.PermissionKey }) + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + }); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -228,6 +273,7 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderExternalReceipts { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Trades { get; set; } + public DbSet Areas { get; set; } public DbSet Vendors { get; set; } public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } @@ -259,6 +305,7 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + public DbSet UserPermissionOverrides { get; set; } public override int SaveChanges() { diff --git a/Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs b/Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs new file mode 100644 index 0000000..829daa8 --- /dev/null +++ b/Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs @@ -0,0 +1,18 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + /// + /// Per-person team permission override, keyed by user and canonical + /// permission key. Explicit Allow/Deny rows take precedence over role defaults; + /// (and a missing row) falls back to + /// the role default. + /// + public class UserPermissionOverride + { + public string UserId { get; set; } = null!; + public string PermissionKey { get; set; } = null!; + public UserPermissionState State { get; set; } = UserPermissionState.Unset; + public virtual ApplicationUser? User { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Enums/PermissionEnums.cs b/Data.SeaHavenIndustries/Enums/PermissionEnums.cs new file mode 100644 index 0000000..9c6b014 --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/PermissionEnums.cs @@ -0,0 +1,13 @@ +namespace Data.SeaHavenIndustries.Enums +{ + /// + /// Tri-state state of a per-person team permission override. + /// A missing row behaves the same as . + /// + public enum UserPermissionState + { + Unset = 0, + Allow = 1, + Deny = 2 + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs new file mode 100644 index 0000000..2e442c7 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs @@ -0,0 +1,3977 @@ +// +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("20260916140128_SH278_VendorCompanyArea")] + partial class SH278_VendorCompanyArea + { + /// + 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("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("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("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.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.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("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("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.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.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.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + 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("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.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/20260916140128_SH278_VendorCompanyArea.cs b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.cs new file mode 100644 index 0000000..8c090d4 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH278_VendorCompanyArea : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AreaId", + table: "VendorCompanies", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "Areas", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + NormalizedName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + IsActive = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Areas", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompanies_AreaId", + table: "VendorCompanies", + column: "AreaId"); + + migrationBuilder.CreateIndex( + name: "IX_Areas_NormalizedName", + table: "Areas", + column: "NormalizedName", + unique: true); + + // SH-278 v1 seed of the four dispatcher territories confirmed by Product. + // Names match the merged SH-273 Sites mapping (East / Central / West / + // California). Ids are stable; later renames change only the display name. + // Existing vendor companies stay Unassigned (no backfill). + migrationBuilder.InsertData( + table: "Areas", + columns: new[] { "Id", "Name", "NormalizedName", "IsActive" }, + values: new object[,] + { + { 1, "East", "east", true }, + { 2, "Central", "central", true }, + { 3, "West", "west", true }, + { 4, "California", "california", true } + }); + + migrationBuilder.AddForeignKey( + name: "FK_VendorCompanies_Areas_AreaId", + table: "VendorCompanies", + column: "AreaId", + principalTable: "Areas", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_VendorCompanies_Areas_AreaId", + table: "VendorCompanies"); + + migrationBuilder.DropTable( + name: "Areas"); + + migrationBuilder.DropIndex( + name: "IX_VendorCompanies_AreaId", + table: "VendorCompanies"); + + migrationBuilder.DropColumn( + name: "AreaId", + table: "VendorCompanies"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs new file mode 100644 index 0000000..c150a1c --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs @@ -0,0 +1,3966 @@ +// +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("20260916201532_SH327_UserPermissionOverrides")] + partial class SH327_UserPermissionOverrides + { + /// + 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("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("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("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.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.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("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("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("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("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.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.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.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.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + 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("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.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/20260916201532_SH327_UserPermissionOverrides.cs b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.cs new file mode 100644 index 0000000..732c20d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH327_UserPermissionOverrides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserPermissionOverrides", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + PermissionKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + State = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserPermissionOverrides", x => new { x.UserId, x.PermissionKey }); + table.ForeignKey( + name: "FK_UserPermissionOverrides_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserPermissionOverrides_UserId_PermissionKey", + table: "UserPermissionOverrides", + columns: new[] { "UserId", "PermissionKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserPermissionOverrides"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 7bf6f39..b68965a 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -286,6 +286,35 @@ namespace Data.SeaHavenIndustries.Migrations 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") @@ -2043,6 +2072,28 @@ namespace Data.SeaHavenIndustries.Migrations 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.Vendor", b => { b.Property("Id") @@ -2237,6 +2288,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Address") .HasColumnType("nvarchar(max)"); + b.Property("AreaId") + .HasColumnType("int"); + b.Property("City") .HasColumnType("nvarchar(max)"); @@ -2294,6 +2348,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); + b.HasIndex("AreaId"); + b.HasIndex("NormalizedName") .IsUnique(); @@ -3582,6 +3638,17 @@ namespace Data.SeaHavenIndustries.Migrations 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.Vendor", b => { b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") @@ -3614,6 +3681,16 @@ namespace Data.SeaHavenIndustries.Migrations 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") diff --git a/Data.SeaHavenIndustries/Models/Area.cs b/Data.SeaHavenIndustries/Models/Area.cs new file mode 100644 index 0000000..0d124cb --- /dev/null +++ b/Data.SeaHavenIndustries/Models/Area.cs @@ -0,0 +1,15 @@ +namespace Data.SeaHavenIndustries +{ + // SH-278: dispatcher territory catalogue for this Sea Haven deployment. Vendor + // companies carry zero or one Area by stable id; the display name is separate and + // may be renamed without changing assignments. Area is selected independently and + // is never derived from a company's State. Rows are organization-wide: vendor + // companies are not scoped to a customer Account. + public class Area + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string NormalizedName { get; set; } = ""; + public bool IsActive { get; set; } = true; + } +} diff --git a/Data.SeaHavenIndustries/Models/VendorCompany.cs b/Data.SeaHavenIndustries/Models/VendorCompany.cs index d83d06b..844a944 100644 --- a/Data.SeaHavenIndustries/Models/VendorCompany.cs +++ b/Data.SeaHavenIndustries/Models/VendorCompany.cs @@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + // SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned. + public int? AreaId { get; set; } + [ForeignKey(nameof(AreaId))] + public Area? Area { get; set; } + [Timestamp] public byte[]? RowVersion { get; set; } diff --git a/SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs b/SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs new file mode 100644 index 0000000..d90a90f --- /dev/null +++ b/SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Dto; + +public sealed class TeamPermissionUserData +{ + public required string UserId { get; init; } + public string? RoleName { get; init; } + public IReadOnlyDictionary Overrides { get; init; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs new file mode 100644 index 0000000..b968a8b --- /dev/null +++ b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs @@ -0,0 +1,101 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation; + +public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideDataService +{ + private readonly ApplicationDbContext _context; + + public TeamPermissionOverrideDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetUserAsync( + string userId, + CancellationToken cancellationToken) + { + var userExists = await _context.Users + .AsNoTracking() + .AnyAsync(user => user.Id == userId, cancellationToken); + + if (!userExists) + return null; + + var roleName = await ( + from userRole in _context.UserRoles.AsNoTracking() + join role in _context.Roles.AsNoTracking() + on userRole.RoleId equals role.Id + where userRole.UserId == userId + select role.Name) + .FirstOrDefaultAsync(cancellationToken); + + var overrides = await _context.UserPermissionOverrides + .AsNoTracking() + .Where(permission => permission.UserId == userId) + .ToDictionaryAsync( + permission => permission.PermissionKey, + permission => permission.State, + StringComparer.OrdinalIgnoreCase, + cancellationToken); + + return new TeamPermissionUserData + { + UserId = userId, + RoleName = roleName, + Overrides = overrides + }; + } + + public async Task SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + CancellationToken cancellationToken) + { + var existing = await _context.UserPermissionOverrides + .SingleOrDefaultAsync( + permission => permission.UserId == userId + && permission.PermissionKey == permissionKey, + cancellationToken); + + if (existing is not null) + { + existing.State = state; + await _context.SaveChangesAsync(cancellationToken); + return; + } + + var addition = new UserPermissionOverride + { + UserId = userId, + PermissionKey = permissionKey, + State = state + }; + await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken); + + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // A concurrent PUT inserted the same (UserId, PermissionKey) between our + // existence check and this save, violating PK_UserPermissionOverrides. + // Converge on the caller's intent by updating the persisted row. + _context.Entry(addition).State = EntityState.Detached; + + var winner = await _context.UserPermissionOverrides + .SingleAsync( + permission => permission.UserId == userId + && permission.PermissionKey == permissionKey, + cancellationToken); + winner.State = state; + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 055f870..f5dbb70 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -135,6 +135,10 @@ namespace SeaHaven.DataServices.Implementation .Where(role => role.UserId == id) .ExecuteDeleteAsync(cancellationToken); + await _context.UserPermissionOverrides + .Where(permissionOverride => permissionOverride.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + await _context.Users .Where(existingUser => existingUser.Id == id) .ExecuteDeleteAsync(cancellationToken); diff --git a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs index aacdf9c..cac9637 100644 --- a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs @@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation _context = context; } + public Task IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) => + _context.Areas + .AsNoTracking() + .AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken); + public async Task GetRosterAsync( int? vendorId, int? companyId, @@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation Zip = c.Zip, GoogleMapsUrl = c.GoogleMapsUrl, Notes = c.Notes, + AreaId = c.AreaId, + AreaName = c.Area != null ? c.Area.Name : null, RowVersion = c.RowVersion }) .FirstOrDefaultAsync(cancellationToken); @@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation company.Zip = roster.Zip; company.GoogleMapsUrl = roster.GoogleMapsUrl; company.Notes = roster.Notes; + if (roster.AreaIdProvided) + company.AreaId = roster.AreaId; company.LastModificationTime = now; int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null; if (actorId.HasValue) @@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation Zip = roster.Zip, GoogleMapsUrl = roster.GoogleMapsUrl, Notes = roster.Notes, + AreaId = roster.AreaId, CreatedDate = now, createdby = roster.ActorUserId }; @@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation company.GoogleMapsUrl = fields.GoogleMapsUrl; if (fields.Notes != null) company.Notes = fields.Notes; + if (fields.AreaIdProvided) + company.AreaId = fields.AreaId; // Keep the denormalized company columns on existing technicians aligned // with the company row (same invariant SaveRosterAsync maintains). This diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 3c5e70a..2268f53 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation return query.OrderBy(c => c.Id); } + public async Task> GetActiveAreasAsync(CancellationToken cancellationToken) + { + return await _context.Areas + .AsNoTracking() + .Where(a => a.IsActive) + .OrderBy(a => a.Id) + .ToListAsync(cancellationToken); + } + public async Task> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken) => await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken); @@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + VendorAreaFilter? areaFilter = null, CancellationToken cancellationToken = default) { // Phase 1: select and paginate bounded company group keys in SQL. @@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation companies, trades, locations, - jobBuckets); + jobBuckets, + areaFilter); var totalCount = await keyQuery.CountAsync(cancellationToken); @@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation ? new Dictionary() : await _context.VendorCompanies .AsNoTracking() + .Include(c => c.Area) .Where(c => missingCompanyIds.Contains(c.Id)) .ToDictionaryAsync(c => c.Id, cancellationToken); @@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst), GoogleMapsUrl = primary.GoogleMapsUrl, Notes = linked ? primary.CompanyNotes : null, + AreaId = linked ? primary.CompanyAreaId : null, + AreaName = linked ? primary.CompanyAreaName : null, IsActive = primary.IsActive, TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs), Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician @@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = null, GoogleMapsUrl = company.GoogleMapsUrl, Notes = company.Notes, + AreaId = company.AreaId, + AreaName = company.Area?.Name, IsActive = true, TotalJobs = 0, Technicians = Array.Empty() @@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, - IReadOnlyCollection? jobBuckets = null) + IReadOnlyCollection? jobBuckets = null, + VendorAreaFilter? areaFilter = null) { var sourceRows = BuildDirectoryUnionBaseQuery(isActive) - .Select(BuildGroupSourceRowSelector(search, companies, trades, locations)); + .Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter)); var grouped = sourceRows .GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey }) @@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation SearchMatch = group.Max(row => row.SearchFlag), CompanyMatch = group.Max(row => row.CompanyFlag), TradeMatch = group.Max(row => row.TradeFlag), - LocationMatch = group.Max(row => row.LocationFlag) + LocationMatch = group.Max(row => row.LocationFlag), + AreaMatch = group.Max(row => row.AreaFlag) }); // Each facet selects the group when any status-eligible row matches it @@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation if (selectedLocations is { Length: > 0 }) grouped = grouped.Where(key => key.LocationMatch == 1); + if (areaFilter != null) + grouped = grouped.Where(key => key.AreaMatch == 1); + var selectedJobBuckets = jobBuckets? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) @@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation string? search, IReadOnlyCollection? companies, IReadOnlyCollection? trades, - IReadOnlyCollection? locations) + IReadOnlyCollection? locations, + VendorAreaFilter? areaFilter) { var row = Expression.Parameter(typeof(VendorDirectoryRow), "row"); @@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation var locationBody = selectedLocations is { Length: > 0 } ? BuildLocationMatchBody(row, selectedLocations) : null; + var areaBody = areaFilter == null + ? null + : BuildAreaMatchBody(row, areaFilter); var nameKey = Expression.Condition( Expression.NotEqual( @@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)), Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)), Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)), - Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))), + Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)), + Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))), row); } @@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation CompanyCity = v.Company != null ? v.Company.City : null, CompanyState = v.Company != null ? v.Company.State : null, CompanyNotes = v.Company != null ? v.Company.Notes : null, + CompanyAreaId = v.Company != null ? v.Company.AreaId : null, + CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null, ContactName = v.ContactName, Email = v.Email, Phone = v.Phone, @@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = v.TradeSpecialties, GoogleMapsUrl = v.GoogleMapsUrl, Notes = v.Company != null ? v.Company.Notes : null, + AreaId = v.Company != null ? v.Company.AreaId : null, IsActive = v.IsActive, TotalJobs = (int?)jobCount.TotalJobs ?? 0 }; @@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation TradeSpecialties = null, GoogleMapsUrl = c.GoogleMapsUrl, Notes = c.Notes, + AreaId = c.AreaId, IsActive = true, TotalJobs = 0 }); @@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation return body!; } + // SH-278: OR within the Area facet. Selected ids match the company's stable + // AreaId; the Unassigned sentinel matches rows without an Area, which includes + // unlinked legacy vendors that have no VendorCompany at all. A filter with no + // resolvable ids and no Unassigned matches nobody. + private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter) + { + var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId)); + Expression? body = null; + + if (areaFilter.AreaIds.Count > 0) + { + var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray(); + body = OrElse(body, Expression.Call( + typeof(Enumerable), + nameof(Enumerable.Contains), + new[] { typeof(int?) }, + Expression.Constant(ids), + areaId)); + } + + if (areaFilter.IncludeUnassigned) + body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?)))); + + return body ?? Expression.Constant(false); + } + private static Expression BuildCompanyNameMatchBody( ParameterExpression row, IReadOnlyCollection selectedCompanies) diff --git a/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs new file mode 100644 index 0000000..49d57e2 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs @@ -0,0 +1,14 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Dto; + +namespace SeaHaven.DataServices.Interfaces; + +public interface ITeamPermissionOverrideDataService +{ + Task GetUserAsync(string userId, CancellationToken cancellationToken); + Task SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + CancellationToken cancellationToken); +} diff --git a/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs index 45a7937..e2e6b62 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs @@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces { public interface IVendorCompanyRosterDataService { + // SH-278: true only for an existing, non-archived Area in the catalogue. + Task IsActiveAreaAsync(int areaId, CancellationToken cancellationToken); + // Loads the company roster from either a vendor row id and/or a company id, // returning the company plus all non-deleted technician rows. Task GetRosterAsync( diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 2e08912..f0002ba 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces // of technician Vendor.TradeSpecialties free text. Task> GetActiveTradesAsync(CancellationToken cancellationToken); + // SH-278: active (non-archived) Area catalogue rows for vendor facets and for + // resolving areas[n] filter ids. Archived Areas are excluded. + Task> GetActiveAreasAsync(CancellationToken cancellationToken); + // SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies, // with status semantics aligned to BuildDirectoryUnionQuery. Null returns all // companies; active includes companies with zero non-deleted technicians or at @@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + VendorAreaFilter? areaFilter = null, CancellationToken cancellationToken = default); Task AddAsync(Vendor vendor); Task UpdateAsync(Vendor vendor); diff --git a/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs index 33e1225..92b4a55 100644 --- a/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs +++ b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs @@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public byte[]? RowVersion { get; set; } public List Technicians { get; set; } = new(); } @@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: applied only when AreaIdProvided; null AreaId means Unassigned. + public bool AreaIdProvided { get; set; } + public int? AreaId { get; set; } public string? ActorUserId { get; set; } public List Technicians { get; set; } = new(); } @@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: applied only when AreaIdProvided; null AreaId means Unassigned. + public bool AreaIdProvided { get; set; } + public int? AreaId { get; set; } } // Raised by the roster data service when a technician removed from the snapshot diff --git a/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs index dff7be9..71eb5d0 100644 --- a/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs +++ b/SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs @@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models public string? TradeSpecialties { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } public bool IsActive { get; set; } public int TotalJobs { get; set; } } + // SH-278: Area facet filter for the company directory. AreaIds are already + // resolved against the active catalogue; IncludeUnassigned selects groups with no + // Area (including unlinked legacy vendors). An empty filter matches nobody. + public sealed class VendorAreaFilter + { + public IReadOnlyCollection AreaIds { get; init; } = Array.Empty(); + public bool IncludeUnassigned { get; init; } + } + // SH-281 company directory: per-row source for the grouped key query. Carries the // company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy // vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with @@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models public int CompanyFlag { get; set; } public int TradeFlag { get; set; } public int LocationFlag { get; set; } + public int AreaFlag { get; set; } } // SH-281: one row per company group after SQL grouping — the bounded key page. @@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models public int CompanyMatch { get; set; } public int TradeMatch { get; set; } public int LocationMatch { get; set; } + public int AreaMatch { get; set; } } // SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own @@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models public string? CompanyCity { get; set; } public string? CompanyState { get; set; } public string? CompanyNotes { get; set; } + public int? CompanyAreaId { get; set; } + public string? CompanyAreaName { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } @@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models public string? TradeSpecialties { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public bool IsActive { get; set; } public int TotalJobs { get; set; } public IReadOnlyList Technicians { get; set; } = Array.Empty(); diff --git a/SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs b/SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs new file mode 100644 index 0000000..0294e22 --- /dev/null +++ b/SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs @@ -0,0 +1,248 @@ +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using SeaHaven.Services.Constants; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace SeaHaven.Services.Tests; + +public class TeamPermissionPolicyTests +{ + private static readonly string[] DispatcherKeys = + { + TeamPermissionKeys.CreateVendors, + TeamPermissionKeys.EditVendors, + TeamPermissionKeys.DeactivateVendors, + TeamPermissionKeys.CreateSites, + TeamPermissionKeys.EditSites, + TeamPermissionKeys.CreateWorkOrders, + TeamPermissionKeys.EditOthersWorkOrders, + TeamPermissionKeys.CancelWorkOrders, + TeamPermissionKeys.RequestUplifts, + TeamPermissionKeys.AutoApproveUplifts + }; + + private static readonly string[] SchedulerOnlyKeys = + { + TeamPermissionKeys.DeleteSites, + TeamPermissionKeys.CreateServices, + TeamPermissionKeys.EditServices, + TeamPermissionKeys.CreateCompletionDocTemplates, + TeamPermissionKeys.EditCompletionDocTemplates, + TeamPermissionKeys.ViewAllDispatchersOnDashboard + }; + + private static readonly string[] AdminOnlyKeys = + { + TeamPermissionKeys.ManageTeamMembers, + TeamPermissionKeys.ChangeTeamMemberRole, + TeamPermissionKeys.DeactivateServices, + TeamPermissionKeys.DeleteCompletionDocTemplates, + TeamPermissionKeys.DeleteWorkOrders, + TeamPermissionKeys.ReviewUplifts + }; + + private static IReadOnlyDictionary Overrides( + string key, UserPermissionState state) => + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [key] = state + }; + + [Fact] + public void Registry_Exposes_Exactly_The_22_Prototype_Keys() + { + TeamPermissionKeys.All.Should().HaveCount(22); + TeamPermissionKeys.KnownKeys.Should().HaveCount(22); + TeamPermissionKeys.All.Should().OnlyHaveUniqueItems(); + TeamPermissionKeys.All.Should().OnlyContain(key => !string.IsNullOrWhiteSpace(key)); + TeamPermissionKeys.All.Should().BeEquivalentTo(new[] + { + "manageTeamMembers", + "changeTeamMemberRole", + "createVendors", + "editVendors", + "deactivateVendors", + "createSites", + "editSites", + "deleteSites", + "createServices", + "editServices", + "deactivateServices", + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", + "createWorkOrders", + "editOthersWorkOrders", + "cancelWorkOrders", + "deleteWorkOrders", + "requestUplifts", + "autoApproveUplifts", + "reviewUplifts", + "viewAllDispatchersOnDashboard" + }); + } + + [Fact] + public void Registry_IsKnown_Is_Case_Insensitive_And_Rejects_Unknown_Keys() + { + TeamPermissionKeys.IsKnown("CREATEVENDORS").Should().BeTrue(); + TeamPermissionKeys.IsKnown("autoApproveUplifts").Should().BeTrue(); + TeamPermissionKeys.IsKnown("nope.unknown").Should().BeFalse(); + TeamPermissionKeys.IsKnown("").Should().BeFalse(); + TeamPermissionKeys.IsKnown(null).Should().BeFalse(); + } + + [Fact] + public void Dispatcher_Gets_Exactly_Prototype_Defaults() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + { + var expected = DispatcherKeys.Contains(key, StringComparer.Ordinal); + policy.IsAllowed("Dispatcher", key).Should().Be(expected, + $"Dispatcher default for '{key}' should be {expected}"); + } + } + + [Fact] + public void Scheduler_Gets_Exactly_Prototype_Defaults() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + { + var expected = SchedulerOnlyKeys.Contains(key, StringComparer.Ordinal) + || DispatcherKeys.Contains(key, StringComparer.Ordinal) + && key is not TeamPermissionKeys.RequestUplifts + && key is not TeamPermissionKeys.AutoApproveUplifts; + + policy.IsAllowed("Scheduler", key).Should().Be(expected, + $"Scheduler default for '{key}' should be {expected}"); + } + } + + [Fact] + public void Admin_Has_Effective_Access_To_Every_Key() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + policy.IsAllowed("Admin", key).Should().BeTrue($"Admin should hold '{key}'"); + } + + [Fact] + public void Role_Recognition_Is_Case_Insensitive() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.CreateVendors).Should().BeTrue(); + policy.IsAllowed("SCHEDULER", TeamPermissionKeys.DeleteSites).Should().BeTrue(); + policy.IsAllowed("admin", TeamPermissionKeys.ReviewUplifts).Should().BeTrue(); + + policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.DeleteSites).Should().BeFalse(); + policy.IsAllowed("SCHEDULER", TeamPermissionKeys.AutoApproveUplifts).Should().BeFalse(); + } + + [Theory] + [InlineData("Manager")] + [InlineData("OfficeAdmin")] + [InlineData("")] + [InlineData(null)] + public void Unknown_And_Legacy_Roles_Receive_No_Permissions(string? role) + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + policy.IsAllowed(role, key).Should().BeFalse( + $"unknown/legacy role '{role}' must not hold '{key}'"); + } + + [Fact] + public void Unknown_Role_Gains_Nothing_Even_With_Allow_Overrides() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + policy.IsAllowed("Manager", key, Overrides(key, UserPermissionState.Allow)) + .Should().BeFalse($"an unknown role must not be elevated via '{key}'"); + } + + [Fact] + public void Allow_Override_Grants_Key_Outside_Role_Default() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("Scheduler", TeamPermissionKeys.RequestUplifts) + .Should().BeFalse(); + policy.IsAllowed( + "Scheduler", + TeamPermissionKeys.RequestUplifts, + Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Allow)) + .Should().BeTrue(); + } + + [Fact] + public void Deny_Override_Removes_Key_Inside_Role_Default() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("Dispatcher", TeamPermissionKeys.CreateVendors) + .Should().BeTrue(); + policy.IsAllowed( + "Dispatcher", + TeamPermissionKeys.CreateVendors, + Overrides(TeamPermissionKeys.CreateVendors, UserPermissionState.Deny)) + .Should().BeFalse(); + } + + [Fact] + public void Unset_Override_And_Missing_Row_Fall_Back_To_Role_Default() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed( + "Dispatcher", + TeamPermissionKeys.CreateWorkOrders, + Overrides(TeamPermissionKeys.CreateWorkOrders, UserPermissionState.Unset)) + .Should().BeTrue(); + + policy.IsAllowed( + "Scheduler", + TeamPermissionKeys.RequestUplifts, + Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Unset)) + .Should().BeFalse(); + + policy.IsAllowed( + "Scheduler", + TeamPermissionKeys.RequestUplifts, + new Dictionary()) + .Should().BeFalse(); + } + + [Fact] + public void Admin_Is_Immune_To_Overrides() + { + var policy = new TeamPermissionPolicy(); + + foreach (var key in TeamPermissionKeys.All) + { + policy.IsAllowed("Admin", key, Overrides(key, UserPermissionState.Deny)) + .Should().BeTrue($"Admin must keep '{key}' despite a Deny override"); + policy.IsAllowed("ADMIN", key, Overrides(key, UserPermissionState.Unset)) + .Should().BeTrue(); + } + } + + [Fact] + public void Unknown_Permission_Key_Is_Denied_For_Every_Role() + { + var policy = new TeamPermissionPolicy(); + + policy.IsAllowed("Admin", "nope.unknown").Should().BeFalse(); + policy.IsAllowed("Dispatcher", "nope.unknown").Should().BeFalse(); + policy.IsAllowed("Admin", "nope.unknown", Overrides("nope.unknown", UserPermissionState.Allow)) + .Should().BeFalse(); + } +} diff --git a/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs b/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs new file mode 100644 index 0000000..048c94b --- /dev/null +++ b/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs @@ -0,0 +1,130 @@ +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace SeaHaven.Services.Tests; + +public sealed class TeamPermissionServiceTests +{ + [Fact] + public async Task GetProfile_ReturnsEveryKeyAndRoleDefaults() + { + var data = new FakeDataService("u1", "Dispatcher"); + var result = await Service(data).GetProfileAsync("u1", Admin(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Permissions.Should().HaveCount(22); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue(); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeFalse(); + result.Value.Permissions.Should().OnlyContain(p => p.OverrideState == UserPermissionState.Unset); + } + + [Fact] + public async Task SetOverride_ChangesOnlyOnePermissionAndPersistsCanonicalKey() + { + var data = new FakeDataService("u1", "Dispatcher"); + var result = await Service(data).SetOverrideAsync( + "u1", "DELETEsites", UserPermissionState.Allow, Admin(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeTrue(); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).OverrideState.Should().Be(UserPermissionState.Allow); + result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue(); + data.LastSet.Should().Be(("u1", TeamPermissionKeys.DeleteSites, UserPermissionState.Allow)); + } + + [Fact] + public async Task SetOverride_RejectsUnknownKeyBeforeDataAccess() + { + var data = new FakeDataService("u1", "Dispatcher"); + var result = await Service(data).SetOverrideAsync( + "u1", "not-a-permission", UserPermissionState.Allow, Admin(), CancellationToken.None); + + result.Status.Should().Be(TeamPermissionResultStatus.InvalidPermissionKey); + data.GetCalls.Should().Be(0); + } + + [Fact] + public async Task NonAdminCannotReadOrWritePermissions() + { + var data = new FakeDataService("u1", "Dispatcher"); + var service = Service(data); + + var read = await service.GetProfileAsync("u1", User("Dispatcher"), CancellationToken.None); + var write = await service.SetOverrideAsync("u1", TeamPermissionKeys.CreateSites, UserPermissionState.Deny, User("Dispatcher"), CancellationToken.None); + + read.Status.Should().Be(TeamPermissionResultStatus.Forbidden); + write.Status.Should().Be(TeamPermissionResultStatus.Forbidden); + data.GetCalls.Should().Be(0); + } + + [Fact] + public async Task UnknownRoleReceivesNoGrantEvenWhenAnOverrideIsSet() + { + var data = new FakeDataService("u1", "Legacy"); + var result = await Service(data).SetOverrideAsync( + "u1", TeamPermissionKeys.CreateSites, UserPermissionState.Allow, Admin(), CancellationToken.None); + + result.Value!.Permissions.Should().OnlyContain(p => !p.IsGranted); + } + + [Fact] + public async Task MissingUserReturnsNotFound() + { + var data = new FakeDataService(null, null); + var result = await Service(data).GetProfileAsync("missing", Admin(), CancellationToken.None); + + result.Status.Should().Be(TeamPermissionResultStatus.NotFound); + } + + private static TeamPermissionService Service(FakeDataService data) => + new(data, new TeamPermissionPolicy()); + + private static ClaimsPrincipal Admin() => User("Admin"); + + private static ClaimsPrincipal User(string role) => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, role) }, "test")); + + private sealed class FakeDataService : ITeamPermissionOverrideDataService + { + private readonly string? _userId; + private readonly string? _roleName; + private readonly Dictionary _overrides = new(StringComparer.OrdinalIgnoreCase); + + public FakeDataService(string? userId, string? roleName) + { + _userId = userId; + _roleName = roleName; + } + + public int GetCalls { get; private set; } + public (string UserId, string PermissionKey, UserPermissionState State)? LastSet { get; private set; } + + public Task GetUserAsync(string userId, CancellationToken cancellationToken) + { + GetCalls++; + return Task.FromResult(_userId != userId + ? null + : new TeamPermissionUserData + { + UserId = userId, + RoleName = _roleName, + Overrides = new Dictionary(_overrides, StringComparer.OrdinalIgnoreCase) + }); + } + + public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken) + { + _overrides[permissionKey] = state; + LastSet = (userId, permissionKey, state); + return Task.CompletedTask; + } + } +} diff --git a/SeaHaven.Services/Constants/TeamPermissionKeys.cs b/SeaHaven.Services/Constants/TeamPermissionKeys.cs new file mode 100644 index 0000000..ceae372 --- /dev/null +++ b/SeaHaven.Services/Constants/TeamPermissionKeys.cs @@ -0,0 +1,67 @@ +namespace SeaHaven.Services.Constants +{ + /// + /// Canonical, immutable registry of team-members permission keys. + /// Exactly the 22 approved prototype keys; stored overrides must reference + /// these keys. Keys are stable identifiers and must never be renamed. + /// + public static class TeamPermissionKeys + { + public const string ManageTeamMembers = "manageTeamMembers"; + public const string ChangeTeamMemberRole = "changeTeamMemberRole"; + public const string CreateVendors = "createVendors"; + public const string EditVendors = "editVendors"; + public const string DeactivateVendors = "deactivateVendors"; + public const string CreateSites = "createSites"; + public const string EditSites = "editSites"; + public const string DeleteSites = "deleteSites"; + public const string CreateServices = "createServices"; + public const string EditServices = "editServices"; + public const string DeactivateServices = "deactivateServices"; + public const string CreateCompletionDocTemplates = "createCompletionDocTemplates"; + public const string EditCompletionDocTemplates = "editCompletionDocTemplates"; + public const string DeleteCompletionDocTemplates = "deleteCompletionDocTemplates"; + public const string CreateWorkOrders = "createWorkOrders"; + public const string EditOthersWorkOrders = "editOthersWorkOrders"; + public const string CancelWorkOrders = "cancelWorkOrders"; + public const string DeleteWorkOrders = "deleteWorkOrders"; + public const string RequestUplifts = "requestUplifts"; + public const string AutoApproveUplifts = "autoApproveUplifts"; + public const string ReviewUplifts = "reviewUplifts"; + public const string ViewAllDispatchersOnDashboard = "viewAllDispatchersOnDashboard"; + + private static readonly IReadOnlyList AllKeys = Array.AsReadOnly(new[] + { + ManageTeamMembers, + ChangeTeamMemberRole, + CreateVendors, + EditVendors, + DeactivateVendors, + CreateSites, + EditSites, + DeleteSites, + CreateServices, + EditServices, + DeactivateServices, + CreateCompletionDocTemplates, + EditCompletionDocTemplates, + DeleteCompletionDocTemplates, + CreateWorkOrders, + EditOthersWorkOrders, + CancelWorkOrders, + DeleteWorkOrders, + RequestUplifts, + AutoApproveUplifts, + ReviewUplifts, + ViewAllDispatchersOnDashboard + }); + + public static IReadOnlyList All => AllKeys; + + public static IReadOnlySet KnownKeys { get; } = + new HashSet(AllKeys, StringComparer.OrdinalIgnoreCase); + + public static bool IsKnown(string? permissionKey) => + !string.IsNullOrWhiteSpace(permissionKey) && KnownKeys.Contains(permissionKey); + } +} diff --git a/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs b/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs new file mode 100644 index 0000000..d930a83 --- /dev/null +++ b/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs @@ -0,0 +1,49 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs; + +public sealed class TeamPermissionProfileDTO +{ + public required string UserId { get; init; } + public string? RoleName { get; init; } + public required IReadOnlyList Permissions { get; init; } +} + +public sealed class TeamPermissionValueDTO +{ + public required string PermissionKey { get; init; } + public UserPermissionState OverrideState { get; init; } + public bool IsGranted { get; init; } +} + +public sealed class SetTeamPermissionOverrideDTO +{ + public UserPermissionState State { get; init; } +} + +public enum TeamPermissionResultStatus +{ + Success, + Forbidden, + NotFound, + InvalidPermissionKey +} + +public sealed class TeamPermissionResult +{ + private TeamPermissionResult(TeamPermissionResultStatus status, T? value) + { + Status = status; + Value = value; + } + + public TeamPermissionResultStatus Status { get; } + public T? Value { get; } + public bool IsSuccess => Status == TeamPermissionResultStatus.Success; + + public static TeamPermissionResult Success(T value) => + new(TeamPermissionResultStatus.Success, value); + + public static TeamPermissionResult Failure(TeamPermissionResultStatus status) => + new(status, default); +} diff --git a/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs index 3e20202..40d9a6d 100644 --- a/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace SeaHaven.Services.DTOs { public class VendorRosterDTO @@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public string? RowVersion { get; set; } public List Technicians { get; set; } = new(); } @@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: optional Area id; null leaves the new company Unassigned. Assigning + // an Area requires the Admin role. + public int? AreaId { get; set; } public List Technicians { get; set; } = new(); } @@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: an omitted areaId leaves the stored Area unchanged, so clients that + // predate Area never clear it. An explicit null clears it to Unassigned. + private int? _areaId; + public int? AreaId + { + get => _areaId; + set + { + _areaId = value; + AreaIdProvided = true; + } + } + + [JsonIgnore] + public bool AreaIdProvided { get; private set; } public List Technicians { get; set; } = new(); } @@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs public string? Zip { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + + // SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId. + private int? _areaId; + public int? AreaId + { + get => _areaId; + set + { + _areaId = value; + AreaIdProvided = true; + } + } + + [JsonIgnore] + public bool AreaIdProvided { get; private set; } + } + + // SH-278: raised when a caller without the Admin role tries to change a vendor + // company's Area. Mapped to a stable 403 by the API. + public sealed class VendorAreaAssignmentForbiddenException : Exception + { + public VendorAreaAssignmentForbiddenException() + : base("Only administrators can assign a vendor company Area.") + { + } } } diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 12fb5b8..0c74465 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs public string? TradeSpecialties { get; set; } public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } + public int? AreaId { get; set; } + public string? AreaName { get; set; } public int TotalJobs { get; set; } public bool IsActive { get; set; } public List Technicians { get; set; } = new(); diff --git a/SeaHaven.Services/DTOs/VendorPortalDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs index 8af1cfa..714a32f 100644 --- a/SeaHaven.Services/DTOs/VendorPortalDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs @@ -59,7 +59,21 @@ namespace SeaHaven.Services.DTOs { public IEnumerable Companies { get; set; } = Enumerable.Empty(); public IEnumerable Trades { get; set; } = Enumerable.Empty(); + public IEnumerable Areas { get; set; } = Enumerable.Empty(); public IEnumerable Locations { get; set; } = Enumerable.Empty(); public IEnumerable JobBuckets { get; set; } = Enumerable.Empty(); } + + // SH-278: one active Area option. Id is the stable areas[n] filter value. + public class VendorFacetAreaDTO + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + // SH-278: wire values accepted by the vendor directory areas[n] filter besides ids. + public static class VendorAreaFilterValues + { + public const string Unassigned = "__unassigned__"; + } } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 3193c08..f17e301 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -204,7 +204,6 @@ namespace SeaHaven.Services.Implementation var today = DashboardBusinessTime.Today(); var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); - var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId); var firstBucketStart = BucketStart(windowFrom, granularity); var lastBucketStart = BucketStart(windowTo, granularity); var lastBucketEnd = BucketEnd(lastBucketStart, granularity); @@ -225,9 +224,7 @@ namespace SeaHaven.Services.Implementation { if (workOrder.ScheduledDate is not DateTime scheduled) continue; - var businessDate = DateOnly.FromDateTime( - TimeZoneInfo.ConvertTimeFromUtc( - DateTime.SpecifyKind(scheduled, DateTimeKind.Utc), timezone)); + var businessDate = DateOnly.FromDateTime(scheduled.Date); if (businessDate < firstBucketStart || businessDate > lastBucketEnd) continue; var bucket = buckets[LocateBucket(bucketStarts, businessDate)]; diff --git a/SeaHaven.Services/Implementation/TeamPermissionPolicy.cs b/SeaHaven.Services/Implementation/TeamPermissionPolicy.cs new file mode 100644 index 0000000..31faa86 --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamPermissionPolicy.cs @@ -0,0 +1,88 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Constants; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public sealed class TeamPermissionPolicy : ITeamPermissionPolicy + { + private static readonly StringComparer KeyComparer = StringComparer.OrdinalIgnoreCase; + + private static readonly HashSet DispatcherDefaults = new(KeyComparer) + { + TeamPermissionKeys.CreateVendors, + TeamPermissionKeys.EditVendors, + TeamPermissionKeys.DeactivateVendors, + TeamPermissionKeys.CreateSites, + TeamPermissionKeys.EditSites, + TeamPermissionKeys.CreateWorkOrders, + TeamPermissionKeys.EditOthersWorkOrders, + TeamPermissionKeys.CancelWorkOrders, + TeamPermissionKeys.RequestUplifts, + TeamPermissionKeys.AutoApproveUplifts + }; + + private static readonly HashSet SchedulerDefaults = new( + DispatcherDefaults + .Except(new[] + { + TeamPermissionKeys.RequestUplifts, + TeamPermissionKeys.AutoApproveUplifts + }, KeyComparer) + .Concat(new[] + { + TeamPermissionKeys.DeleteSites, + TeamPermissionKeys.CreateServices, + TeamPermissionKeys.EditServices, + TeamPermissionKeys.CreateCompletionDocTemplates, + TeamPermissionKeys.EditCompletionDocTemplates, + TeamPermissionKeys.ViewAllDispatchersOnDashboard + }), KeyComparer); + + public bool IsAllowed( + string? roleName, + string permissionKey, + IReadOnlyDictionary? overrides = null) + { + if (!TeamPermissionKeys.IsKnown(permissionKey)) + return false; + + if (IsAdmin(roleName)) + return true; + + var defaults = ResolveRoleDefaults(roleName); + if (defaults is null) + return false; + + if (overrides is not null + && overrides.TryGetValue(permissionKey, out var state)) + { + return state switch + { + UserPermissionState.Allow => true, + UserPermissionState.Deny => false, + _ => defaults.Contains(permissionKey) + }; + } + + return defaults.Contains(permissionKey); + } + + private static bool IsAdmin(string? roleName) => + string.Equals(roleName, "Admin", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlySet? ResolveRoleDefaults(string? roleName) + { + if (string.IsNullOrWhiteSpace(roleName)) + return null; + + if (roleName.Equals("Dispatcher", StringComparison.OrdinalIgnoreCase)) + return DispatcherDefaults; + + if (roleName.Equals("Scheduler", StringComparison.OrdinalIgnoreCase)) + return SchedulerDefaults; + + return null; + } + } +} diff --git a/SeaHaven.Services/Implementation/TeamPermissionService.cs b/SeaHaven.Services/Implementation/TeamPermissionService.cs new file mode 100644 index 0000000..34e6f26 --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamPermissionService.cs @@ -0,0 +1,96 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace SeaHaven.Services.Implementation; + +public sealed class TeamPermissionService : ITeamPermissionService +{ + private readonly ITeamPermissionOverrideDataService _dataService; + private readonly ITeamPermissionPolicy _policy; + + public TeamPermissionService( + ITeamPermissionOverrideDataService dataService, + ITeamPermissionPolicy policy) + { + _dataService = dataService; + _policy = policy; + } + + public async Task> GetProfileAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken) + { + if (!IsAdmin(caller)) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.Forbidden); + + var user = await _dataService.GetUserAsync(userId, cancellationToken); + return user is null + ? TeamPermissionResult.Failure(TeamPermissionResultStatus.NotFound) + : TeamPermissionResult.Success(BuildProfile(user)); + } + + public async Task> SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + ClaimsPrincipal caller, + CancellationToken cancellationToken) + { + if (!IsAdmin(caller)) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.Forbidden); + + var canonicalKey = TeamPermissionKeys.All.FirstOrDefault( + key => string.Equals(key, permissionKey, StringComparison.OrdinalIgnoreCase)); + if (canonicalKey is null) + return TeamPermissionResult.Failure( + TeamPermissionResultStatus.InvalidPermissionKey); + + var user = await _dataService.GetUserAsync(userId, cancellationToken); + if (user is null) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.NotFound); + + await _dataService.SetOverrideAsync(userId, canonicalKey, state, cancellationToken); + + var updatedOverrides = user.Overrides.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.OrdinalIgnoreCase); + updatedOverrides[canonicalKey] = state; + + return TeamPermissionResult.Success( + BuildProfile(new TeamPermissionUserData + { + UserId = user.UserId, + RoleName = user.RoleName, + Overrides = updatedOverrides + })); + } + + private TeamPermissionProfileDTO BuildProfile(TeamPermissionUserData user) + { + return new TeamPermissionProfileDTO + { + UserId = user.UserId, + RoleName = user.RoleName, + Permissions = TeamPermissionKeys.All + .Select(key => new TeamPermissionValueDTO + { + PermissionKey = key, + OverrideState = user.Overrides.TryGetValue(key, out var state) + ? state + : UserPermissionState.Unset, + IsGranted = _policy.IsAllowed(user.RoleName, key, user.Overrides) + }) + .ToList() + }; + } + + private static bool IsAdmin(ClaimsPrincipal? caller) => + caller?.IsInRole("Admin") == true; +} diff --git a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs index c5b7676..0cd1169 100644 --- a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs +++ b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs @@ -6,6 +6,7 @@ using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; +using System.Security.Claims; namespace SeaHaven.Services.Implementation { @@ -13,6 +14,8 @@ namespace SeaHaven.Services.Implementation { private const int MaxNotesLength = 500; private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters."; + private const string AdminRole = "Admin"; + private const string AreaNotFoundMessage = "Area was not found."; private readonly IVendorCompanyRosterDataService _rosterDataService; @@ -39,6 +42,7 @@ namespace SeaHaven.Services.Implementation public async Task CreateRosterAsync( CreateVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken) { EnsureAuthenticated(userId); @@ -60,6 +64,13 @@ namespace SeaHaven.Services.Implementation "Technician ids are not allowed when creating a vendor company.") }); + await EnsureAreaAssignmentAllowedAsync( + caller, + dto.AreaId, + currentAreaId: null, + nameof(CreateVendorRosterDTO.AreaId), + cancellationToken); + var writeModel = new VendorCompanyRosterWriteModel { Name = dto.Name, @@ -71,6 +82,8 @@ namespace SeaHaven.Services.Implementation Zip = dto.Zip, GoogleMapsUrl = dto.GoogleMapsUrl, Notes = dto.Notes, + AreaIdProvided = true, + AreaId = dto.AreaId, ActorUserId = userId, Technicians = MapTechnicians(dto.Technicians) }; @@ -83,6 +96,7 @@ namespace SeaHaven.Services.Implementation int companyId, ReconcileVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken) { EnsureAuthenticated(userId); @@ -101,6 +115,14 @@ namespace SeaHaven.Services.Implementation // Mismatched ids: every non-null technician id must belong to this company. await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken); + if (dto.AreaIdProvided) + await EnsureAreaChangeAllowedAsync( + companyId, + caller, + dto.AreaId, + nameof(ReconcileVendorRosterDTO.AreaId), + cancellationToken); + var writeModel = new VendorCompanyRosterWriteModel { CompanyId = companyId, @@ -114,6 +136,8 @@ namespace SeaHaven.Services.Implementation Zip = dto.Zip, GoogleMapsUrl = dto.GoogleMapsUrl, Notes = dto.Notes, + AreaIdProvided = dto.AreaIdProvided, + AreaId = dto.AreaId, ActorUserId = userId, Technicians = MapTechnicians(dto.Technicians) }; @@ -126,6 +150,7 @@ namespace SeaHaven.Services.Implementation int companyId, AddTechniciansVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken) { EnsureAuthenticated(userId); @@ -167,6 +192,14 @@ namespace SeaHaven.Services.Implementation if (failures.Count > 0) throw new ValidationException(failures); + if (companyFields is { AreaIdProvided: true }) + await EnsureAreaChangeAllowedAsync( + companyId, + caller, + companyFields.AreaId, + $"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}", + cancellationToken); + // The contact-group invariant ("at least one company phone or email") is // preserved by construction: blank company fields map to null ("unchanged"), // so this endpoint can only set valid phone/email values, never clear them. @@ -184,6 +217,50 @@ namespace SeaHaven.Services.Implementation return MapToDTO(saved); } + // SH-278: Area assignment is Admin-only. A payload that leaves the stored Area + // unchanged is allowed for any authenticated caller, so non-admin users can + // still save other company fields while round-tripping the current Area. + private async Task EnsureAreaChangeAllowedAsync( + int companyId, + ClaimsPrincipal caller, + int? requestedAreaId, + string propertyName, + CancellationToken cancellationToken) + { + var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken) + ?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found."); + + await EnsureAreaAssignmentAllowedAsync( + caller, + requestedAreaId, + roster.AreaId, + propertyName, + cancellationToken); + } + + private async Task EnsureAreaAssignmentAllowedAsync( + ClaimsPrincipal caller, + int? requestedAreaId, + int? currentAreaId, + string propertyName, + CancellationToken cancellationToken) + { + if (requestedAreaId == currentAreaId) + return; + + if (caller?.IsInRole(AdminRole) != true) + throw new VendorAreaAssignmentForbiddenException(); + + if (requestedAreaId is int areaId + && (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken))) + { + throw new ValidationException(new[] + { + new ValidationFailure(propertyName, AreaNotFoundMessage) + }); + } + } + private static void EnsureAuthenticated(string userId) { if (string.IsNullOrWhiteSpace(userId)) @@ -290,7 +367,8 @@ namespace SeaHaven.Services.Implementation || !string.IsNullOrWhiteSpace(fields.State) || !string.IsNullOrWhiteSpace(fields.Zip) || !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) - || !string.IsNullOrWhiteSpace(fields.Notes); + || !string.IsNullOrWhiteSpace(fields.Notes) + || fields.AreaIdProvided; private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) => model.Name == null @@ -301,7 +379,8 @@ namespace SeaHaven.Services.Implementation && model.State == null && model.Zip == null && model.GoogleMapsUrl == null - && model.Notes == null; + && model.Notes == null + && !model.AreaIdProvided; private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields( VendorRosterCompanyFieldsDTO fields, @@ -344,7 +423,9 @@ namespace SeaHaven.Services.Implementation State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State, Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip, GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl, - Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes + Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes, + AreaIdProvided = fields.AreaIdProvided, + AreaId = fields.AreaId }; } @@ -443,6 +524,8 @@ namespace SeaHaven.Services.Implementation Zip = roster.Zip, GoogleMapsUrl = roster.GoogleMapsUrl, Notes = roster.Notes, + AreaId = roster.AreaId, + AreaName = roster.AreaName, RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion), Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO { diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 5e4ce40..2d715a3 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -4,6 +4,7 @@ using FluentValidation; using FluentValidation.Results; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.Globalization; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.Configuration; @@ -160,10 +161,12 @@ namespace SeaHaven.Services.Implementation IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + IReadOnlyCollection? areas = null, CancellationToken cancellationToken = default) { var normalizedPage = Math.Max(1, page); var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize); + var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken); var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync( normalizedPage, normalizedPageSize, @@ -173,6 +176,7 @@ namespace SeaHaven.Services.Implementation trades, locations, jobBuckets, + areaFilter, cancellationToken); return new PagedResult @@ -184,6 +188,47 @@ namespace SeaHaven.Services.Implementation }; } + // SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects + // companies without an Area; other values must be stable positive integer ids + // of active catalogue rows. Display names, unknown ids and archived ids never + // error — they simply match nobody. Null means no Area filter was requested. + private async Task ResolveAreaFilterAsync( + IReadOnlyCollection? areas, + CancellationToken cancellationToken) + { + var values = areas? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .ToList(); + + if (values is not { Count: > 0 }) + return null; + + var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal); + var requestedIds = new HashSet(); + foreach (var value in values) + { + if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0) + requestedIds.Add(id); + } + + IReadOnlyCollection activeIds = Array.Empty(); + if (requestedIds.Count > 0) + { + var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken); + activeIds = activeAreas + .Select(area => area.Id) + .Where(requestedIds.Contains) + .ToArray(); + } + + return new VendorAreaFilter + { + AreaIds = activeIds, + IncludeUnassigned = includeUnassigned + }; + } + private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new() { Id = group.Id, @@ -201,6 +246,8 @@ namespace SeaHaven.Services.Implementation TradeSpecialties = group.TradeSpecialties, GoogleMapsUrl = group.GoogleMapsUrl, Notes = group.Notes, + AreaId = group.AreaId, + AreaName = group.AreaName, IsActive = group.IsActive, TotalJobs = group.TotalJobs, Technicians = group.Technicians.Select(MapToTechnician).ToList() @@ -731,6 +778,7 @@ namespace SeaHaven.Services.Implementation // populated even on an empty database and independent of the status filter. var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken); var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken); + var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken); var companyFacets = companies .Where(c => !string.IsNullOrWhiteSpace(c.Name)) @@ -772,10 +820,19 @@ namespace SeaHaven.Services.Implementation .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label); + // SH-278: the active Area catalogue, A–Z case-insensitive, independent of the + // status filter and of whether any company currently uses an Area. Clients + // prepend the "__unassigned__" option themselves. + var areas = activeAreas + .OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase) + .Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name }) + .ToList(); + return new VendorFacetsDTO { Companies = companyFacets, Trades = trades, + Areas = areas, Locations = locations, JobBuckets = new[] { diff --git a/SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs b/SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs new file mode 100644 index 0000000..1d66616 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs @@ -0,0 +1,23 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Interfaces +{ + /// + /// Pure evaluator for team-members permissions. Applies explicit + /// per-person overrides on top of role defaults; Admin has effective access + /// to every canonical key regardless of stored values. Unknown or legacy + /// roles receive no permissions. + /// + public interface ITeamPermissionPolicy + { + /// + /// Resolves whether a user in holds + /// . is the + /// person's stored override set (missing key behaves as Unset). + /// + bool IsAllowed( + string? roleName, + string permissionKey, + IReadOnlyDictionary? overrides = null); + } +} diff --git a/SeaHaven.Services/Interfaces/ITeamPermissionService.cs b/SeaHaven.Services/Interfaces/ITeamPermissionService.cs new file mode 100644 index 0000000..d4b3892 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamPermissionService.cs @@ -0,0 +1,20 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; +using System.Security.Claims; + +namespace SeaHaven.Services.Interfaces; + +public interface ITeamPermissionService +{ + Task> GetProfileAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken); + + Task> SetOverrideAsync( + string userId, + string permissionKey, + UserPermissionState state, + ClaimsPrincipal caller, + CancellationToken cancellationToken); +} diff --git a/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs index 27d9d79..5b48cc0 100644 --- a/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs +++ b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces @@ -13,18 +14,21 @@ namespace SeaHaven.Services.Interfaces Task CreateRosterAsync( CreateVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken); Task ReconcileRosterAsync( int companyId, ReconcileVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken); Task AddTechniciansAsync( int companyId, AddTechniciansVendorRosterDTO dto, string userId, + ClaimsPrincipal caller, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 74782af..185cb29 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -41,6 +41,7 @@ namespace SeaHaven.Services.Interfaces IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, + IReadOnlyCollection? areas = null, CancellationToken cancellationToken = default); Task CreateVendorAsync(CreateVendorDTO dto, string userId); Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);