diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 0eaa5b4..544aa1d 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -232,35 +232,45 @@ public class AuthenticationControllerTests } [Fact] - public async Task ForgetPassword_Found_ReturnsCheckEmailMessage() + public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered() { var service = new Mock(); - service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny())).ReturnsAsync(true); - var controller = NewController(service); - var result = await controller.ForgetPassword("a@b.com", CancellationToken.None); + var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None); + var unregistered = await controller.ForgetPassword(null, "x@y.com", CancellationToken.None); - var ok = result.Should().BeOfType().Subject; + var ok = registered.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("Success "); - response.Message.Should().Be("Please check your email for code"); + response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); + Json(unregistered.Should().BeOfType().Subject.Value).Should().Be(Json(ok.Value)); + service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny()), Times.Once); + service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny()), Times.Once); } [Fact] - public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage() + public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail() { var service = new Mock(); - service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET a@b.com")); + var logger = new Mock>(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var controller = new AuthenticationController(service.Object, logger.Object); - var controller = NewController(service); + var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None); - var result = await controller.ForgetPassword("x@y.com", CancellationToken.None); - - var bad = result.Should().BeOfType().Subject; - var response = bad.Value.Should().BeOfType().Subject; - response.Status.Should().Be("Error"); - response.Message.Should().Be("No such email is registered"); + var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; + response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")), + null, + It.IsAny>()), + Times.Once); } [Theory] @@ -269,11 +279,11 @@ public class AuthenticationControllerTests public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage) { var service = new Mock(); - service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny())).ReturnsAsync(matched); + service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny())).ReturnsAsync(matched); var controller = NewController(service); - var result = await controller.VerificationCode("123456", CancellationToken.None); + var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None); if (matched) { @@ -291,6 +301,33 @@ public class AuthenticationControllerTests } } + [Fact] + public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService() + { + var service = new Mock(); + var controller = NewController(service); + + var result = await controller.VerificationCode(null, null, "123456", CancellationToken.None); + + result.Should().BeOfType().Subject.Value.Should().BeOfType() + .Which.Message.Should().Be("Code Not Matched"); + service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny()), Times.Once); + } + + [Fact] + public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError() + { + var service = new Mock(); + service.Setup(s => s.VerifyCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail")); + var controller = NewController(service); + + var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, null, null, CancellationToken.None); + + Json(result.Should().BeOfType().Subject.Value) + .Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" })); + } + [Fact] public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage() { diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index a26fb62..37e1b01 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -6,6 +6,7 @@ using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -27,7 +28,7 @@ public class AuthenticationServiceTests hasher = h; var jwtOptions = Microsoft.Extensions.Options.Options.Create(JwtOptions); var sessionStamps = new SessionStampService(userData.Object, new InMemorySessionStampCache(TimeProvider.System), jwtOptions); - return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, sessionStamps); + return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, TimeProvider.System, sessionStamps); } private static JwtOptions JwtOptions => new() @@ -101,31 +102,36 @@ public class AuthenticationServiceTests } [Fact] - public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail() + public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode() { var user = IdentityTestHelpers.User(); var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); var forget = new Mock(); var email = new Mock(); + string? stored = null, salt = null, body = null; + DateTime expires = default; + forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; }) + .Returns(Task.CompletedTask); + email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny())) + .Callback((_, _, b) => body = b) + .ReturnsAsync(true); var service = NewService(userData, forget, email, out _, out _); + var before = DateTime.UtcNow; - var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); - found.Should().BeTrue(); - forget.Verify( - f => f.ReplaceCodeAsync( - user.Email!, - user.Id, - It.Is(code => code.Length == 6 && code.All(char.IsDigit)), - It.IsAny()), - Times.Once); - email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is(b => b.Contains("Your Password Reset Code is:"))), Times.Once); + var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value; + code.Should().HaveLength(6); + stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$"); + PasswordResetCodeSecrets.Matches(salt!, code, stored!).Should().BeTrue(); + expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5)); } [Fact] - public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode() + public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip() { var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); @@ -134,40 +140,90 @@ public class AuthenticationServiceTests var service = NewService(userData, forget, email, out _, out _); - var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None); + await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None); + + forget.Verify(f => f.RemoveByEmailAsync("nope@example.com", It.IsAny()), Times.Once); + forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityTestHelpers.User(isDeleted: true)); + var forget = new Mock(); + var email = new Mock(); + + var service = NewService(userData, forget, email, out _, out _); + + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); - found.Should().BeFalse(); - forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task VerifyCode_ForwardsDataServiceResult(bool exists) + [InlineData(null, "123456")] + [InlineData("", "123456")] + [InlineData(" ", "123456")] + [InlineData("a@b.com", null)] + [InlineData("a@b.com", "")] + public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code) { - var forget = new Mock(); - forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny())).ReturnsAsync(exists); + var forget = new Mock(MockBehavior.Strict); var service = NewService(new Mock(), forget, new Mock(), out _, out _); - var result = await service.VerifyCodeAsync("abc", CancellationToken.None); + var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None); - result.Should().Be(exists); + result.Should().BeFalse(); } [Fact] - public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset() + public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset() { var forget = new Mock(); - forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + forget.Setup(f => f.GetByEmailAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ForgetPasswordCode?)null); var service = NewService(new Mock(), forget, new Mock(), out var store, out _); - var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None); + var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None); result.Should().BeFalse(); store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); - forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails() + { + var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash("s", "123456"), FailedAttempts = 5 }; + var forget = new Mock(); + forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); + forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + + var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None); + + result.Should().BeFalse(); + forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny()), Times.Once); + store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("123456", "123456", true)] + [InlineData("123456", " 123456 ", true)] + [InlineData("123456", "123457", false)] + public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected) + { + var salt = PasswordResetCodeSecrets.NewSalt(); + var hash = PasswordResetCodeSecrets.Hash(salt, issued); + + PasswordResetCodeSecrets.Matches(salt, candidate, hash).Should().Be(expected); + PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash); + PasswordResetCodeSecrets.Matches("", issued, hash).Should().BeFalse(); + PasswordResetCodeSecrets.Matches(salt, issued, "").Should().BeFalse(); } } diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs index 3f5a7a0..b028f78 100644 --- a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -9,6 +9,7 @@ using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; @@ -133,6 +134,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable Mock.Of(), Mock.Of(), Mock.Of(), + TimeProvider.System, Mock.Of()); var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); @@ -170,10 +172,19 @@ public sealed class PasswordPolicyTests : IAsyncDisposable { var user = await CreateUserAsync(); var forget = new Mock(); - forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny())) - .ReturnsAsync(true); + var salt = PasswordResetCodeSecrets.NewSalt(); forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny())) - .ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" }); + .ReturnsAsync(new ForgetPasswordCode + { + Id = 7, + Email = user.Email!, + UserId = user.Id, + CodeSalt = salt, + CodeHash = PasswordResetCodeSecrets.Hash(salt, "123456"), + ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10) + }); + forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); var service = NewAuthenticationService(forget); var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None); @@ -181,6 +192,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable reset.Should().BeFalse(); (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny()), Times.Once); } private async Task> ValidateAsync(ApplicationUser user, string password) @@ -210,6 +222,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable Mock.Of(), (forget ?? new Mock()).Object, Mock.Of(), + TimeProvider.System, Mock.Of()); public async ValueTask DisposeAsync() diff --git a/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs b/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs new file mode 100644 index 0000000..41a45df --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs @@ -0,0 +1,276 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Revoke rules exercised end to end through both revoke endpoints (the Uplift +/// Approvals route and the work-order route) against the real services, so a +/// refusal is observed as the HTTP result and the unchanged stored state. +/// +public sealed class UpliftRevokeEndpointRulesTests +{ + private const int WorkOrderId = 1; + private const int DispatchId = 10; + private const int AutoApprovedId = 100; + private const int AdminApprovedId = 101; + + public enum RevokeRoute + { + UpliftApprovals, + WorkOrder, + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ClaimsPrincipal OrgWideUser(string userId, string role) => + new(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + }, + "test")); + + private static async Task SeedAsync(ApplicationDbContext context) + { + context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false }); + context.Users.Add(new ApplicationUser { Id = "admin-1", UserName = "admin-1", FirstName = "Ada", LastName = "Admin" }); + context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Dee", LastName = "Dispatcher" }); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = DispatchId, + VendorId = 1, + WorkOrderId = WorkOrderId, + NTEAmount = 2200m, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + context.workOrders.Add(new WorkOrder + { + Id = WorkOrderId, + InternalWONumber = "10000000001", + PrimaryDispatchId = DispatchId, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + LifecycleStatus = LifecycleStatus.Scheduled, + }); + // The admin filed this one themselves and it auto-approved within the allowance. + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = AutoApprovedId, + DispatchId = DispatchId, + CurrentNTE = 1000m, + RequestedNTE = 400m, + Status = UpliftStatus.NoApprovalRequired, + RequiredTier = 0, + NotificationStatus = "Sent", + createdby = "admin-1", + CreatedDate = DateTime.UtcNow.AddHours(-2), + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = AdminApprovedId, + DispatchId = DispatchId, + CurrentNTE = 1400m, + RequestedNTE = 800m, + Status = UpliftStatus.Approved, + RequiredTier = 1, + NotificationStatus = "Sent", + createdby = "dispatcher-1", + CreatedDate = DateTime.UtcNow.AddHours(-1), + DecidedAt = DateTime.UtcNow.AddMinutes(-30), + DecidedByUserId = "admin-1", + }); + await context.SaveChangesAsync(); + } + + private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) => + new( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)), + new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions())); + + private static async Task RevokeAsync( + ApplicationDbContext context, + RevokeRoute route, + ClaimsPrincipal user, + int upliftId, + string? reason) + { + var workOrderFlow = NewWorkOrderUpliftService(context); + var httpContext = new DefaultHttpContext { User = user }; + + if (route == RevokeRoute.WorkOrder) + { + var controller = new WorkOrderDetailController( + Mock.Of(), + Mock.Of(), + workOrderFlow, + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext }, + }; + return await controller.RevokeUplift( + WorkOrderId, + upliftId, + new RevokeWorkOrderUpliftRequestDto { Reason = reason }, + CancellationToken.None); + } + + var upliftService = new UpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + workOrderFlow); + var approvals = new UpliftController(upliftService, Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext }, + }; + return await approvals.Revoke( + upliftId, + new UpliftController.DecisionRequest { Note = reason }, + CancellationToken.None); + } + + private static async Task AssertUnchangedAsync(ApplicationDbContext context, int upliftId, string status) + { + var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId); + stored.Status.Should().Be(status); + stored.DecisionNote.Should().BeNull(); + (await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(2200m); + (await context.WorkOrderAuditLogs.AsNoTracking().AnyAsync(a => a.Action == "uplift_revoke")).Should().BeFalse(); + } + + [Fact] + public async Task WorkOrderRoute_AdminRevokingAutoApprovedUpliftTheyRequested_IsForbiddenAndChangesNothing() + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync( + context, + RevokeRoute.WorkOrder, + OrgWideUser("admin-1", "Admin"), + AutoApprovedId, + "Wrong quote attached"); + + var refused = result.Should().BeOfType().Subject; + refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + refused.Value.Should().BeOfType().Which.Message + .Should().StartWith("You are not authorized to perform this action"); + await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired); + } + + [Fact] + public async Task ApprovalsRoute_AdminRevokingAutoApprovedUplift_IsRefusedAndChangesNothing() + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync( + context, + RevokeRoute.UpliftApprovals, + OrgWideUser("admin-1", "Admin"), + AutoApprovedId, + "Wrong quote attached"); + + var refused = result.Should().BeOfType().Subject; + refused.Value.Should().BeOfType().Which.Message + .Should().StartWith("This uplift request cannot be revoked"); + await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired); + } + + [Theory] + [InlineData(RevokeRoute.UpliftApprovals)] + [InlineData(RevokeRoute.WorkOrder)] + public async Task AdminRevokingAdminApprovedUpliftWithReason_Succeeds(RevokeRoute route) + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync( + context, + route, + OrgWideUser("admin-1", "Admin"), + AdminApprovedId, + " Approved against the wrong quote "); + + result.Should().BeOfType(); + var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AdminApprovedId); + stored.Status.Should().Be(UpliftStatus.Revoked); + stored.DecisionNote.Should().Be("Approved against the wrong quote"); + (await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1400m); + var audit = await context.WorkOrderAuditLogs.AsNoTracking().SingleAsync(a => a.Action == "uplift_revoke"); + audit.OldValue.Should().Be(UpliftStatus.Approved); + audit.NewValue.Should().Be(UpliftStatus.Revoked); + } + + [Theory] + [InlineData(RevokeRoute.UpliftApprovals)] + [InlineData(RevokeRoute.WorkOrder)] + public async Task AdminRevokingAdminApprovedUpliftWithoutReason_IsRefused(RevokeRoute route) + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync(context, route, OrgWideUser("admin-1", "Admin"), AdminApprovedId, " "); + + result.Should().BeOfType(); + await AssertUnchangedAsync(context, AdminApprovedId, UpliftStatus.Approved); + } + + [Fact] + public async Task WorkOrderRoute_DispatcherRevokingOwnAutoApprovedUpliftWithoutReason_Succeeds() + { + await using var context = CreateContext(); + await SeedAsync(context); + var own = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == AutoApprovedId); + own.createdby = "dispatcher-1"; + await context.SaveChangesAsync(); + + var result = await RevokeAsync( + context, + RevokeRoute.WorkOrder, + OrgWideUser("dispatcher-1", "Dispatcher"), + AutoApprovedId, + null); + + result.Should().BeOfType(); + var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AutoApprovedId); + stored.Status.Should().Be(UpliftStatus.Revoked); + stored.DecisionNote.Should().BeNull(); + (await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1000m); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index efccb2b..2d711fd 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -1,11 +1,15 @@ using Api.SeaHavenIndustries.DTOs; using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.Infrastructure; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; +using System.Runtime.CompilerServices; using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers @@ -108,30 +112,47 @@ namespace Api.SeaHavenIndustries.Controllers #region Forget Password Area + public const string ForgetPasswordMessage = + "If that email belongs to an account, a reset code has been sent to it."; + + // Email and code are read from the JSON body so they stay out of URLs and proxy + // access logs; the query-string form is still accepted for older clients. [AllowAnonymous] [HttpPost()] [Route("ForgetPassword")] - public async Task ForgetPassword(string Email, CancellationToken cancellationToken) - { - var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken); - if (found) - { - return Ok(new Response { Status = "Success ", Message = "Please check your email for code" }); - } - else - { - return BadRequest(new Response { Status = "Error", Message = "No such email is registered" }); - } - } - //need email and code - [AllowAnonymous] - [HttpPost()] - [Route("VerificationCode")] - public async Task VerificationCode(string code, CancellationToken cancellationToken) + [EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)] + public async Task ForgetPassword( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body, + [FromQuery(Name = "Email")] string? email, + CancellationToken cancellationToken) { try { - if (await _authenticationService.VerifyCodeAsync(code, cancellationToken)) + await _authenticationService.ForgetPasswordAsync(body?.Email ?? email, cancellationToken); + } + catch (Exception ex) + { + // Same answer as success: a failure only a registered address can hit + // must not reveal that the address is registered. + LogResetFailure(ex); + } + + return Ok(new Response { Status = "Success ", Message = ForgetPasswordMessage }); + } + + [AllowAnonymous] + [HttpPost()] + [Route("VerificationCode")] + [EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)] + public async Task VerificationCode( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] VerificationCode_Dto? body, + [FromQuery] string? email, + [FromQuery] string? code, + CancellationToken cancellationToken) + { + try + { + if (await _authenticationService.VerifyCodeAsync(body?.Email ?? email, body?.Code ?? code, cancellationToken)) { return Ok(new Response { Status = "Success ", Message = "Code Matched" }); @@ -143,14 +164,15 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + LogResetFailure(ex); + return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" }); } } - // need email, password and code [AllowAnonymous] [HttpPost()] [Route("ResetPassword")] + [EnableRateLimiting(PasswordResetRateLimiting.ResetPasswordPolicy)] public async Task ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken) { try @@ -166,11 +188,21 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); - + LogResetFailure(ex); + return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" }); } } + + // These endpoints answer failures exactly like a wrong code or an unknown email, so + // an error only a registered account can trigger reveals nothing. Exception + // messages here can echo the email or code, so only the type is logged. + private void LogResetFailure(Exception exception, [CallerMemberName] string operation = "") + { + _logger.LogError( + "Password reset {Operation} failed with {ExceptionType}.", + operation, + exception.GetType().FullName); + } #endregion } } diff --git a/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs b/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs index 3ad9bae..1a06931 100644 --- a/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs +++ b/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs @@ -4,9 +4,20 @@ namespace Api.SeaHavenIndustries.DTOs { public class ForgetPassword_Dto { - public string Email { get; set; } + public string? Email { get; set; } [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] public string? Password { get; set; } public string? Code { get; set; } } + + public class ForgetPasswordRequest_Dto + { + public string? Email { get; set; } + } + + public class VerificationCode_Dto + { + public string? Email { get; set; } + public string? Code { get; set; } + } } diff --git a/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs b/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs new file mode 100644 index 0000000..a9b25d5 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Threading.RateLimiting; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; + +namespace Api.SeaHavenIndustries.Infrastructure; + +/// +/// Per-client limits on the anonymous password reset endpoints, plus the +/// forwarded-header trust that makes "per client" mean the caller and not the proxy. +/// +public static class PasswordResetRateLimiting +{ + public const string ForgetPasswordPolicy = "password-reset-request"; + public const string VerificationCodePolicy = "password-reset-verify"; + public const string ResetPasswordPolicy = "password-reset-confirm"; + + public const int PermitLimit = 10; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(15); + + public const string RejectedMessage = "Too many requests. Please try again later."; + + /// + /// The API runs on Elastic Beanstalk behind an application load balancer and the + /// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For + /// is read right to left through loopback and private (VPC) hops only; the first + /// public address is the client. Entries a caller writes further left are ignored. + /// + public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services) + { + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor; + options.ForwardLimit = null; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8)); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8)); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12)); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16)); + options.KnownProxies.Add(IPAddress.IPv6Loopback); + }); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync( + new Response { Status = "Error", Message = RejectedMessage }, + cancellationToken); + }; + + AddPerClientPolicy(options, ForgetPasswordPolicy); + AddPerClientPolicy(options, VerificationCodePolicy); + AddPerClientPolicy(options, ResetPasswordPolicy); + }); + + return services; + } + + public static string ClientPartitionKey(HttpContext context) + { + var address = context.Connection.RemoteIpAddress; + if (address == null) + return "unknown"; + return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString(); + } + + private static void AddPerClientPolicy(RateLimiterOptions options, string policyName) + { + options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter( + ClientPartitionKey(context), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = PermitLimit, + Window = Window, + QueueLimit = 0, + AutoReplenishment = true + })); + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 99cfe30..f82c666 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -56,6 +56,7 @@ builder.Services.AddResponseCompression(opts => opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( new[] { "application/octet-stream" }); }); +builder.Services.AddPasswordResetRateLimiting(); builder.Services.AddCors(option => option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); @@ -172,6 +173,9 @@ builder.Services.AddSwaggerGen(c => var app = builder.Build(); +// First, so every later middleware and the rate limiter see the real client address. +app.UseForwardedHeaders(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment() || app.Environment.IsProduction()) { @@ -189,6 +193,7 @@ app.UseStaticFiles(); app.UseCors(); app.UseMiddleware(); app.UseRouting(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); diff --git a/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs new file mode 100644 index 0000000..59a88ee --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs @@ -0,0 +1,4282 @@ +// +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("20260925153843_HashPasswordResetCodes")] + partial class HashPasswordResetCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsAccountOwner") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingRegistration") + .HasColumnType("bit"); + + b.Property("PendingRegistrationCreatedDate") + .HasColumnType("datetime2"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("IsAccountOwner") + .IsUnique() + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner") + .HasFilter("IsAccountOwner = 1"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExtraSafetyNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId", "SortOrder"); + + b.ToTable("CompletionDocTemplateProcedures"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailedAttempts") + .HasColumnType("int"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ServiceId"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany("Procedures") + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany("Contacts") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("ServiceAreas"); + + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Navigation("Procedures"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Contacts"); + + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.cs b/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.cs new file mode 100644 index 0000000..9ad344b --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class HashPasswordResetCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Pending codes were stored in plaintext with no expiry. They are + // short-lived, so they are discarded rather than migrated; affected users + // request a new code. + migrationBuilder.Sql("DELETE FROM [ForgetPasswordCodes];"); + + migrationBuilder.AddColumn( + name: "CodeHash", + table: "ForgetPasswordCodes", + type: "nvarchar(64)", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "CodeSalt", + table: "ForgetPasswordCodes", + type: "nvarchar(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ExpiresAtUtc", + table: "ForgetPasswordCodes", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "FailedAttempts", + table: "ForgetPasswordCodes", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CodeHash", + table: "ForgetPasswordCodes"); + + migrationBuilder.DropColumn( + name: "CodeSalt", + table: "ForgetPasswordCodes"); + + migrationBuilder.DropColumn( + name: "ExpiresAtUtc", + table: "ForgetPasswordCodes"); + + migrationBuilder.DropColumn( + name: "FailedAttempts", + table: "ForgetPasswordCodes"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 7e3f322..78c4d11 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1527,10 +1527,26 @@ namespace Data.SeaHavenIndustries.Migrations .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + b.Property("Email") .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailedAttempts") + .HasColumnType("int"); + b.Property("UserId") .IsRequired() .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs b/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs index 6a59aeb..3573851 100644 --- a/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs +++ b/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -11,6 +12,23 @@ namespace Data.SeaHavenIndustries public int Id { get; set; } public string UserId { get; set; } public string Email { get; set; } - public string Code { get; set; } + + /// + /// Legacy plaintext column. It is always written empty; only + /// is compared. + /// + public string Code { get; set; } = string.Empty; + + /// Lowercase hex SHA-256 of ":code". + [MaxLength(64)] + public string CodeHash { get; set; } = string.Empty; + + [MaxLength(32)] + public string CodeSalt { get; set; } = string.Empty; + + public DateTime ExpiresAtUtc { get; set; } + + /// Checks consumed against this code, including concurrent ones still in flight. + public int FailedAttempts { get; set; } } } diff --git a/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs b/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs new file mode 100644 index 0000000..e976eea --- /dev/null +++ b/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs @@ -0,0 +1,26 @@ +namespace SeaHaven.DataServices.Helpers +{ + /// + /// Which stored uplift statuses still count as a live uplift on a work order. + /// Cancelled (legacy "Cancelled", "Withdrawn", "Expired") and revoked uplifts do not; + /// pending, approved, auto-approved and rejected ones do. The advanced-search + /// "Has uplift" filter and the board Uplift column both use this, so they cannot drift. + /// + public static class LiveUpliftStatus + { + /// + /// Stored statuses that are not live. Kept as an array so EF translates + /// NonLive.Contains(u.Status) inside query predicates. + /// + public static readonly string[] NonLive = + { + "Withdrawn", + "Cancelled", + "Expired", + "Revoked", + }; + + public static bool IsLive(string? status) => + status == null || !NonLive.Contains(status, StringComparer.Ordinal); + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 12d22ce..e14972f 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -238,11 +238,13 @@ namespace SeaHaven.DataServices.Helpers CancellationToken cancellationToken) { var workOrderIds = workOrders.Select(w => w.Id); + var nonLive = LiveUpliftStatus.NonLive; var live = context.DispatchUpliftRequests .AsNoTracking() .Where(u => (u.IsDeleted == null || u.IsDeleted == false) + && !nonLive.Contains(u.Status) && u.Dispatch != null && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)); diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs index d3bdfea..61afea5 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs @@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers WorkOrderType.Reactive, }; - private static readonly string[] RevokedUpliftStatuses = - { - "Withdrawn", - "Cancelled", - }; - public static IQueryable ApplySeverityFilter( IQueryable query, IReadOnlyList? severities) @@ -125,18 +119,21 @@ namespace SeaHaven.DataServices.Helpers if (!hasUplift) return query; + // No sub-filter: any live uplift. An explicit sub-filter matches exactly the + // statuses it names, so "cancelled" / "revoked" still find those work orders. var mappedStatuses = MapUpliftStatuses(upliftStatuses); + var anyLive = mappedStatuses.Count == 0; + var nonLive = LiveUpliftStatus.NonLive; return query.Where(w => context.DispatchUpliftRequests.Any(u => (u.IsDeleted == null || u.IsDeleted == false) - && u.Status != "Expired" - && !RevokedUpliftStatuses.Contains(u.Status) && u.Dispatch != null && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false) && ( u.Dispatch.WorkOrderId == w.Id || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)) - && (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status)))); + && ((anyLive && !nonLive.Contains(u.Status)) + || (!anyLive && mappedStatuses.Contains(u.Status))))); } internal static List MapUpliftStatuses(IReadOnlyList? upliftStatuses) @@ -166,6 +163,14 @@ namespace SeaHaven.DataServices.Helpers mapped.Add("Rejected"); mapped.Add("Denied"); break; + case "cancelled": + mapped.Add("Withdrawn"); + mapped.Add("Cancelled"); + mapped.Add("Expired"); + break; + case "revoked": + mapped.Add("Revoked"); + break; } } diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs index c1781a2..fe3bd1d 100644 --- a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -13,71 +13,76 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken) + public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken) { - var normalizedEmail = email.ToLower().Trim(); + var normalizedEmail = Normalize(email); var existing = await _context.ForgetPasswordCodes .Where(u => u.Email.ToLower().Trim() == normalizedEmail) - .FirstOrDefaultAsync(cancellationToken); + .ToListAsync(cancellationToken); + _context.ForgetPasswordCodes.RemoveRange(existing); - if (existing != null) - { - _context.ForgetPasswordCodes.Remove(existing); - await _context.SaveChangesAsync(cancellationToken); - } - - var forgetPasswordCode = new ForgetPasswordCode + _context.ForgetPasswordCodes.Add(new ForgetPasswordCode { Email = email, UserId = userId, - Code = code - }; - - _context.ForgetPasswordCodes.Add(forgetPasswordCode); + Code = string.Empty, + CodeHash = codeHash, + CodeSalt = codeSalt, + ExpiresAtUtc = expiresAtUtc, + FailedAttempts = 0 + }); await _context.SaveChangesAsync(cancellationToken); } - public async Task CodeExistsAsync(string code, CancellationToken cancellationToken) - { - return await _context.ForgetPasswordCodes - .AsNoTracking() - .Where(u => u.Code == code) - .AnyAsync(cancellationToken); - } - - public async Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken) - { - var normalizedEmail = email.ToLower().Trim(); - var trimmedCode = code.Trim(); - - return await _context.ForgetPasswordCodes - .AsNoTracking() - .Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode) - .AnyAsync(cancellationToken); - } - public async Task GetByEmailAsync(string email, CancellationToken cancellationToken) { - var normalizedEmail = email.ToLower().Trim(); + var normalizedEmail = Normalize(email); return await _context.ForgetPasswordCodes + .AsNoTracking() .Where(u => u.Email.ToLower().Trim() == normalizedEmail) + .OrderByDescending(u => u.Id) .FirstOrDefaultAsync(cancellationToken); } + public async Task TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken) + { + // A single conditional UPDATE, so concurrent checks can never consume + // more than maxAttempts between them. + var updated = await _context.ForgetPasswordCodes + .Where(u => u.Id == id && u.FailedAttempts < maxAttempts && u.ExpiresAtUtc > nowUtc) + .ExecuteUpdateAsync( + setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts + 1), + cancellationToken); + return updated == 1; + } + + public async Task RefundAttemptAsync(int id, CancellationToken cancellationToken) + { + await _context.ForgetPasswordCodes + .Where(u => u.Id == id && u.FailedAttempts > 0) + .ExecuteUpdateAsync( + setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts - 1), + cancellationToken); + } + public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) { - var normalizedEmail = email.ToLower().Trim(); - var records = await _context.ForgetPasswordCodes + var normalizedEmail = Normalize(email); + await _context.ForgetPasswordCodes .Where(code => code.Email.ToLower().Trim() == normalizedEmail) - .ToListAsync(cancellationToken); - - if (records.Count == 0) - return; - - _context.ForgetPasswordCodes.RemoveRange(records); - await _context.SaveChangesAsync(cancellationToken); + .ExecuteDeleteAsync(cancellationToken); } + + public async Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) + { + var normalizedEmail = Normalize(email); + await _context.ForgetPasswordCodes + .Where(code => code.Email.ToLower().Trim() == normalizedEmail && code.Id <= throughId) + .ExecuteDeleteAsync(cancellationToken); + } + + private static string Normalize(string email) => email.ToLower().Trim(); } } diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs index 720e665..1c7f0ad 100644 --- a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -4,10 +4,27 @@ namespace SeaHaven.DataServices.Interfaces { public interface IForgetPasswordDataService { - Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken); - Task CodeExistsAsync(string code, CancellationToken cancellationToken); - Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken); + /// Deletes every pending code for the email, then stores the new one. + Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken); + + /// Returns the pending code for exactly this email, or null. Task GetByEmailAsync(string email, CancellationToken cancellationToken); + + /// + /// Atomically consumes one attempt when the code is unexpired and has fewer + /// than consumed. Returns false otherwise. + /// + Task TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken); + + /// Gives back an attempt consumed by a check that matched. + Task RefundAttemptAsync(int id, CancellationToken cancellationToken); + Task RemoveByEmailAsync(string email, CancellationToken cancellationToken); + + /// + /// Deletes the email's codes issued up to and including , + /// leaving any code issued after it by a concurrent request. + /// + Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs b/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs new file mode 100644 index 0000000..39762dd --- /dev/null +++ b/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Generation and hashing for emailed password reset codes. The raw code exists + /// only in memory and in the email sent to the account holder. + /// + public static class PasswordResetCodeSecrets + { + public static string NewCode() + { + return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture); + } + + public static string NewSalt() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + } + + public static string Hash(string salt, string code) + { + ArgumentNullException.ThrowIfNull(salt); + ArgumentNullException.ThrowIfNull(code); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant(); + } + + public static bool Matches(string salt, string candidate, string expectedHash) + { + if (string.IsNullOrEmpty(salt) || string.IsNullOrEmpty(expectedHash)) + return false; + + var actual = Encoding.ASCII.GetBytes(Hash(salt, candidate.Trim())); + var expected = Encoding.ASCII.GetBytes(expectedHash); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + } +} diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 543f8a2..db30bca 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -9,7 +9,6 @@ using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Security.Cryptography; using System.Text; namespace SeaHaven.Services.Implementation @@ -21,13 +20,19 @@ namespace SeaHaven.Services.Implementation private readonly IUserDataService _userDataService; private readonly IForgetPasswordDataService _forgetPasswordDataService; private readonly IEmailSender _emailSender; + private readonly TimeProvider _timeProvider; private readonly ISessionStampService _sessionStamps; + + public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15); + public const int MaxCodeAttempts = 5; + public AuthenticationService( UserManager userManager, IOptions jwtOptions, IUserDataService userDataService, IForgetPasswordDataService forgetPasswordDataService, IEmailSender emailSender, + TimeProvider timeProvider, ISessionStampService sessionStamps) { _userManager = userManager; @@ -35,6 +40,7 @@ namespace SeaHaven.Services.Implementation _userDataService = userDataService; _forgetPasswordDataService = forgetPasswordDataService; _emailSender = emailSender; + _timeProvider = timeProvider; _sessionStamps = sessionStamps; } @@ -148,50 +154,104 @@ namespace SeaHaven.Services.Implementation }; } - public async Task ForgetPasswordAsync(string email, CancellationToken cancellationToken) + public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken) { - var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken); - if (user == null) return false; + // Registered and unregistered addresses take the same path up to the + // email send: one user lookup, one code generated and hashed, one write. + var requested = email?.Trim() ?? string.Empty; + var user = requested.Length == 0 + ? null + : await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken); + var code = PasswordResetCodeSecrets.NewCode(); + var salt = PasswordResetCodeSecrets.NewSalt(); + var hash = PasswordResetCodeSecrets.Hash(salt, code); - var code = GenerateRandomNo(); - await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken); - var body = $"Your Password Reset Code is: " + code; - await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body); + if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email)) + { + await _forgetPasswordDataService.RemoveByEmailAsync(requested, cancellationToken); + return; + } + + var expiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.Add(ResetCodeLifetime); + await _forgetPasswordDataService.ReplaceCodeAsync(user.Email, user.Id, hash, salt, expiresAtUtc, cancellationToken); + var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; + await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body); + } + + public async Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken) + { + var pending = await CheckCodeAsync(email, code, cancellationToken); + if (pending == null) + return false; + + // Verifying is a preview step; a correct code keeps all of its attempts. + await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken); return true; } - public async Task VerifyCodeAsync(string code, CancellationToken cancellationToken) + public async Task ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken) { - return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken); - } - - public async Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password)) + if (string.IsNullOrWhiteSpace(password)) return false; - var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken); - if (!matched) return false; - - var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); - if (record == null) + var pending = await CheckCodeAsync(email, code, cancellationToken); + if (pending == null) return false; - var user = await _userManager.FindByIdAsync(record.UserId); - if (user == null) + var user = await _userManager.FindByIdAsync(pending.UserId); + if (user == null || user.IsDeleted == true) + { + await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); return false; + } var token = await _userManager.GeneratePasswordResetTokenAsync(user); // A reset rotates the security stamp, which ends every earlier session. var result = await _userManager.ResetPasswordAsync(user, token, password); if (!result.Succeeded) + { + // The code was right and the new password was rejected: the user can + // try another password without spending an attempt. + await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken); return false; + } _sessionStamps.Forget(user.Id); - await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken); + await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return true; } + /// + /// Returns the pending code record when matches the one + /// issued to . Every check consumes an attempt before + /// comparing; a check that uses the last attempt without matching deletes the code. + /// + private async Task CheckCodeAsync(string? email, string? code, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code)) + return null; + + var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); + if (pending == null) + return null; + + var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; + // Deletes below stop at this code's id: a code issued by a concurrent request + // after this one was read belongs to that request and must survive. + if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken)) + { + await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); + return null; + } + + if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash)) + return pending; + + if (pending.FailedAttempts + 1 >= MaxCodeAttempts) + await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); + return null; + } + private JwtSecurityToken GetToken(List authClaims) { var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret)); @@ -204,10 +264,5 @@ namespace SeaHaven.Services.Implementation ); return token; } - - private static string GenerateRandomNo() - { - return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6"); - } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 0980e0d..87a1834 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -273,6 +273,11 @@ namespace SeaHaven.Services.Implementation if (canonical == UpliftStatus.NoApprovalRequired) { + // An admin revoke overturns a human decision, and an auto-approval has none, + // so admins are refused here even when they filed the request themselves. + if (user.IsInRole("Admin")) + throw new UpliftForbiddenException("Admins can revoke only admin-approved uplifts"); + if (string.IsNullOrWhiteSpace(userId) || !string.Equals(req.createdby, userId, StringComparison.Ordinal)) { diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index 5d41a32..e94dc28 100644 --- a/SeaHaven.Services/Interfaces/IAuthenticationService.cs +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -9,8 +9,13 @@ namespace SeaHaven.Services.Interfaces Task CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken); Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken); Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); - Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); - Task VerifyCodeAsync(string code, CancellationToken cancellationToken); - Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken); + /// + /// Emails a reset code when the address belongs to an active account. Gives no + /// indication either way, so callers cannot learn which emails are registered. + /// + Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken); + /// Checks a code against the one issued to this email only. + Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken); + Task ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken); } } diff --git a/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs new file mode 100644 index 0000000..51f3c70 --- /dev/null +++ b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs @@ -0,0 +1,406 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +/// +/// The anonymous Forgot Password flow over HTTP, against the real controller, +/// services, Identity and a relational database. +/// +public sealed class PasswordResetFlowTests +{ + private const string Alice = "alice@example.com"; + private const string Bob = "bob@example.com"; + private const string OldPassword = "Old@12345"; + private const string NewPassword = "New@67890"; + private const string CodeNotMatched = "{\"status\":\"Error\",\"message\":\"Code Not Matched\"}"; + private const string ResetFailed = "{\"status\":\"Error\",\"message\":\"Your email or code not found please check\"}"; + + [Fact] + public async Task Registered_user_resets_password_end_to_end_and_signs_in_with_the_new_one() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + var requested = await host.ForgetPasswordAsync(Alice); + Assert.Equal(HttpStatusCode.OK, requested.StatusCode); + var code = host.Sent.LatestCodeFor(Alice); + Assert.Matches("^[0-9]{6}$", code); + + var verified = await host.VerifyAsync(Alice, code); + Assert.Equal(HttpStatusCode.OK, verified.StatusCode); + Assert.Contains("Code Matched", await verified.Content.ReadAsStringAsync()); + + var reset = await host.ResetAsync(Alice, code, NewPassword); + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await host.LoginAsync(Alice, OldPassword)).StatusCode); + + // A used code cannot be replayed. + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "Other@24680")).Content.ReadAsStringAsync()); + Assert.Empty(await host.PendingCodesAsync()); + } + + [Fact] + public async Task ForgetPassword_answers_registered_and_unregistered_emails_identically_and_emails_only_the_registered_one() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + var registered = await host.ForgetPasswordAsync(Alice); + var unregistered = await host.ForgetPasswordAsync("nobody@example.com"); + + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + Assert.Equal(registered.StatusCode, unregistered.StatusCode); + Assert.Equal(await registered.Content.ReadAsStringAsync(), await unregistered.Content.ReadAsStringAsync()); + Assert.Contains(AuthenticationController.ForgetPasswordMessage, await registered.Content.ReadAsStringAsync()); + + var message = Assert.Single(host.Sent.Messages); + Assert.Equal(Alice, message.To); + Assert.Single(await host.PendingCodesAsync()); + } + + [Fact] + public async Task ForgetPassword_hides_an_email_delivery_failure_behind_the_same_response() + { + await using var host = await PasswordResetTestHost.StartAsync(new ThrowingEmailSender()); + await host.AddUserAsync(Alice, OldPassword); + + var registered = await host.ForgetPasswordAsync(Alice); + var unregistered = await host.ForgetPasswordAsync("nobody@example.com"); + + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + var body = await registered.Content.ReadAsStringAsync(); + Assert.Equal(await unregistered.Content.ReadAsStringAsync(), body); + Assert.DoesNotContain("smtp-internal-detail", body); + Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(Alice, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Stored_code_is_a_salted_hash_and_the_plaintext_is_only_in_the_email() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + var row = Assert.Single(await host.PendingCodesAsync()); + + Assert.Equal(string.Empty, row.Code); + Assert.Matches("^[0-9a-f]{32}$", row.CodeSalt); + Assert.Matches("^[0-9a-f]{64}$", row.CodeHash); + Assert.NotEqual(Sha256Hex(code), row.CodeHash); + Assert.Equal(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash); + Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId)); + } + + [Fact] + public async Task Code_expires_fifteen_minutes_after_it_is_issued() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + + host.Time.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromSeconds(1)); + Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode); + + host.Time.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode); + } + + [Fact] + public async Task Requesting_a_new_code_invalidates_the_previous_one() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + await host.ForgetPasswordAsync(Alice); + var first = host.Sent.LatestCodeFor(Alice); + string second; + do + { + await host.ForgetPasswordAsync(Alice); + second = host.Sent.LatestCodeFor(Alice); + } + while (second == first); + + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, first)).Content.ReadAsStringAsync()); + Assert.Single(await host.PendingCodesAsync()); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword)).StatusCode); + } + + [Fact] + public async Task Five_wrong_attempts_invalidate_the_code_until_a_new_one_is_requested() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + var wrong = WrongCode(code); + + // Verify and reset share one attempt budget. + for (var attempt = 0; attempt < 3; attempt++) + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, wrong)).Content.ReadAsStringAsync()); + for (var attempt = 0; attempt < 2; attempt++) + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, wrong, NewPassword)).Content.ReadAsStringAsync()); + + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync()); + Assert.Empty(await host.PendingCodesAsync()); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode); + + await host.ForgetPasswordAsync(Alice); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword)).StatusCode); + } + + [Fact] + public async Task Exhausting_a_code_invalidates_every_pending_code_for_that_email() + { + await using var host = await PasswordResetTestHost.StartAsync(); + var user = await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var older = host.Sent.LatestCodeFor(Alice); + var newest = await host.AddSiblingCodeAsync(Alice, user.Id); + + for (var attempt = 0; attempt < 5; attempt++) + await host.VerifyAsync(Alice, WrongCode(newest)); + + Assert.Empty(await host.PendingCodesAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, NewPassword)).Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_successful_reset_invalidates_every_pending_code_for_that_email() + { + await using var host = await PasswordResetTestHost.StartAsync(); + var user = await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var older = host.Sent.LatestCodeFor(Alice); + var newest = await host.AddSiblingCodeAsync(Alice, user.Id); + + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, newest, NewPassword)).StatusCode); + + Assert.Empty(await host.PendingCodesAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, "Other@24680")).Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Four_wrong_attempts_then_the_right_code_still_resets() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + + for (var attempt = 0; attempt < 4; attempt++) + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync()); + + // A correct verify does not spend an attempt, so the reset after it still succeeds. + Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode); + } + + [Fact] + public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + + for (var attempt = 0; attempt < 6; attempt++) + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode); + } + + [Fact] + public async Task A_code_is_checked_only_against_the_email_it_was_issued_to() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.AddUserAsync(Bob, OldPassword); + await host.ForgetPasswordAsync(Alice); + await host.ForgetPasswordAsync(Bob); + var aliceCode = host.Sent.LatestCodeFor(Alice); + var bobCode = host.Sent.LatestCodeFor(Bob); + + if (aliceCode != bobCode) + { + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync()); + } + + // Exhausting Bob's attempts leaves Alice's code and budget untouched. + for (var attempt = 0; attempt < 5; attempt++) + await host.VerifyAsync(Bob, WrongCode(bobCode)); + + var alice = Assert.Single(await host.PendingCodesAsync()); + Assert.Equal(Alice, alice.Email); + Assert.Equal(0, alice.FailedAttempts); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode); + } + + [Fact] + public async Task Concurrent_checks_can_never_consume_more_than_five_attempts() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var row = Assert.Single(await host.PendingCodesAsync()); + var now = host.Time.GetUtcNow().UtcDateTime; + + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ => + { + await using var scope = host.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService() + .TryConsumeAttemptAsync(row.Id, 5, now, CancellationToken.None); + })); + + Assert.Equal(5, results.Count(consumed => consumed)); + Assert.Equal(5, Assert.Single(await host.PendingCodesAsync()).FailedAttempts); + } + + [Fact] + public async Task VerificationCode_without_an_email_fails_generically_and_never_matches_another_users_code() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + + // The old client contract: code in the query string, no body, no content type. + var legacy = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/VerificationCode?code={code}")); + + Assert.Equal(HttpStatusCode.BadRequest, legacy.StatusCode); + Assert.Equal(CodeNotMatched, await legacy.Content.ReadAsStringAsync()); + Assert.Equal(0, Assert.Single(await host.PendingCodesAsync()).FailedAttempts); + } + + [Fact] + public async Task VerificationCode_and_ForgetPassword_still_accept_the_query_string_form() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + var requested = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}")); + Assert.Equal(HttpStatusCode.OK, requested.StatusCode); + var code = host.Sent.LatestCodeFor(Alice); + + var verified = await host.Client.SendAsync(PasswordResetTestHost.Post( + $"api/Authentication/VerificationCode?email={Uri.EscapeDataString(Alice)}&code={code}")); + Assert.Equal(HttpStatusCode.OK, verified.StatusCode); + } + + [Fact] + public async Task Each_endpoint_allows_ten_requests_per_client_per_window_then_answers_429() + { + await using var host = await PasswordResetTestHost.StartAsync(); + const string client = "203.0.113.5"; + + for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++) + Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync($"n{request}@example.com", client)).StatusCode); + + var limited = await host.ForgetPasswordAsync("n@example.com", client); + Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode); + Assert.Equal( + "{\"status\":\"Error\",\"message\":\"Too many requests. Please try again later.\"}", + await limited.Content.ReadAsStringAsync()); + + // A left-hand X-Forwarded-For entry written by the caller does not buy a new bucket. + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.ForgetPasswordAsync("n@example.com", "198.51.100.1, " + client)).StatusCode); + + // Another client, and the same client on another endpoint, are unaffected. + Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync("n@example.com", "203.0.113.6")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync("n@example.com", "000000", client)).StatusCode); + } + + [Theory] + [InlineData("api/Authentication/VerificationCode")] + [InlineData("api/Authentication/ResetPassword")] + public async Task Verify_and_reset_are_rate_limited_per_client(string path) + { + await using var host = await PasswordResetTestHost.StartAsync(); + const string client = "203.0.113.9"; + var body = new { email = Alice, code = "000000", password = NewPassword }; + + for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++) + Assert.Equal(HttpStatusCode.BadRequest, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode); + + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode); + } + + [Fact] + public async Task No_code_or_email_reaches_the_logs_during_the_whole_flow() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + await host.ForgetPasswordAsync(Alice); + await host.ForgetPasswordAsync("nobody@example.com"); + var code = host.Sent.LatestCodeFor(Alice); + await host.VerifyAsync(Alice, WrongCode(code)); + await host.VerifyAsync(Alice, code); + await host.ResetAsync(Alice, code, NewPassword); + + Assert.NotEmpty(host.Logged.Lines); + Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(code, StringComparison.Ordinal)); + Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(WrongCode(code), StringComparison.Ordinal)); + Assert.DoesNotContain(host.Logged.Lines, line => line.Contains("@example.com", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(NewPassword, StringComparison.Ordinal)); + } + + [Fact] + public void Api_host_registers_forwarded_headers_and_rate_limiting_in_the_right_order() + { + var program = File.ReadAllText(Path.Combine(RepoRoot(), "Api.SeaHavenIndustries", "Program.cs")); + + Assert.Contains("builder.Services.AddPasswordResetRateLimiting();", program); + var build = program.IndexOf("builder.Build()", StringComparison.Ordinal); + var forwarded = program.IndexOf("app.UseForwardedHeaders()", StringComparison.Ordinal); + var firstMiddleware = program.IndexOf("app.Use", build, StringComparison.Ordinal); + var routing = program.IndexOf("app.UseRouting()", StringComparison.Ordinal); + var limiter = program.IndexOf("app.UseRateLimiter()", StringComparison.Ordinal); + var controllers = program.IndexOf("app.MapControllers()", StringComparison.Ordinal); + + Assert.True(forwarded > build && forwarded == firstMiddleware, "UseForwardedHeaders must be the first middleware."); + Assert.True(routing < limiter, "UseRateLimiter must run after UseRouting so endpoint policies apply."); + Assert.True(limiter < controllers); + } + + private static string WrongCode(string code) => + ((int.Parse(code) + 1) % 1_000_000).ToString("D6"); + + private static string Sha256Hex(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private static string RepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "Api.SeaHavenIndustries", "Program.cs"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate the repository root from " + AppContext.BaseDirectory); + } + + private sealed class ThrowingEmailSender : IEmailSender + { + public Task SendEmailAsync(string emailTo, string subject, string body) => + throw new InvalidOperationException("smtp-internal-detail " + emailTo); + } +} diff --git a/SeaHavenIndustries.Tests/PasswordResetRaceTests.cs b/SeaHavenIndustries.Tests/PasswordResetRaceTests.cs new file mode 100644 index 0000000..20a89f2 --- /dev/null +++ b/SeaHavenIndustries.Tests/PasswordResetRaceTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using Data.SeaHavenIndustries; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHavenIndustries.Tests; + +/// +/// A new code requested while a check of the previous code is in flight. The +/// request runs for real, between the check's steps, through the same host. +/// +public sealed class PasswordResetRaceTests +{ + private const string Alice = "alice@example.com"; + private const string OldPassword = "Old@12345"; + private const string NewPassword = "New@67890"; + + private static int _nextClient; + + private static string NextClient() + { + var n = Interlocked.Increment(ref _nextClient); + return $"192.0.{n / 250 % 250}.{n % 250 + 1}"; + } + + [Fact] + public async Task A_code_requested_while_the_previous_one_is_being_checked_still_resets() + { + var race = new CheckRace(); + await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice, NextClient()); + var first = host.Sent.LatestCodeFor(Alice); + + // The check has read the first code; the new request replaces it before the attempt is counted. + race.BeforeNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient()); + Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, first, NextClient())).StatusCode); + + Assert.Equal(2, host.Sent.Messages.Count); + var second = host.Sent.LatestCodeFor(Alice); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode); + } + + [Fact] + public async Task A_code_requested_while_the_last_wrong_attempt_is_being_checked_still_resets() + { + var race = new CheckRace(); + await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice, NextClient()); + var wrong = host.Sent.LatestCodeFor(Alice) == "000000" ? "111111" : "000000"; + for (var attempt = 1; attempt < 5; attempt++) + await host.VerifyAsync(Alice, wrong, NextClient()); + + // The fifth wrong attempt is counted, then the new request lands before the used-up code is deleted. + race.AfterNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient()); + Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, wrong, NextClient())).StatusCode); + + Assert.Equal(2, host.Sent.Messages.Count); + var second = host.Sent.LatestCodeFor(Alice); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode); + } + + private sealed class CheckRace + { + private Func? _beforeNextConsume; + private Func? _afterNextConsume; + + public Func? BeforeNextConsume { set => _beforeNextConsume = value; } + public Func? AfterNextConsume { set => _afterNextConsume = value; } + + public void Register(IServiceCollection services) => + services.Replace(ServiceDescriptor.Scoped(provider => + new RacingForgetPasswordDataService(new ForgetPasswordDataService(provider.GetRequiredService()), this))); + + public Task RunBeforeConsumeAsync() => Interlocked.Exchange(ref _beforeNextConsume, null)?.Invoke() ?? Task.CompletedTask; + public Task RunAfterConsumeAsync() => Interlocked.Exchange(ref _afterNextConsume, null)?.Invoke() ?? Task.CompletedTask; + } + + private sealed class RacingForgetPasswordDataService : IForgetPasswordDataService + { + private readonly IForgetPasswordDataService _inner; + private readonly CheckRace _race; + + public RacingForgetPasswordDataService(IForgetPasswordDataService inner, CheckRace race) + { + _inner = inner; + _race = race; + } + + public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken) => + _inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, cancellationToken); + + public Task GetByEmailAsync(string email, CancellationToken cancellationToken) => + _inner.GetByEmailAsync(email, cancellationToken); + + public async Task TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken) + { + await _race.RunBeforeConsumeAsync(); + var consumed = await _inner.TryConsumeAttemptAsync(id, maxAttempts, nowUtc, cancellationToken); + await _race.RunAfterConsumeAsync(); + return consumed; + } + + public Task RefundAttemptAsync(int id, CancellationToken cancellationToken) => + _inner.RefundAttemptAsync(id, cancellationToken); + + public Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) => + _inner.RemoveByEmailAsync(email, cancellationToken); + + public Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) => + _inner.RemoveIssuedThroughAsync(email, throughId, cancellationToken); + } +} diff --git a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs new file mode 100644 index 0000000..323453f --- /dev/null +++ b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs @@ -0,0 +1,298 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Text.RegularExpressions; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.DependencyInjection; +using SeaHaven.Services.DependencyInjection; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +/// +/// Hosts the real on Kestrel over a SQLite file +/// database, with the real authentication service, data services, Identity, and the +/// same rate limiting and forwarded-header registration the API host uses. Email goes +/// to an in-memory sender, the clock is manual, and every log line is captured. +/// +internal sealed class PasswordResetTestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly string _databasePath; + + private PasswordResetTestHost(WebApplication app, string databasePath, HttpClient client) + { + _app = app; + _databasePath = databasePath; + Client = client; + } + + public HttpClient Client { get; } + public ResetEmailOutbox Sent { get; private init; } = null!; + public ResetTestClock Time { get; private init; } = null!; + public ResetLogCapture Logged { get; private init; } = null!; + public IServiceProvider Services => _app.Services; + + public static async Task StartAsync(IEmailSender? emailSender = null, Action? configureServices = null) + { + var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db"); + var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString(); + + var sent = new ResetEmailOutbox(); + var time = new ResetTestClock(); + var logged = new ResetLogCapture(); + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["JWT:Secret"] = new string('k', 64), + ["JWT:ValidIssuer"] = "issuer", + ["JWT:ValidAudience"] = "audience" + }); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Trace); + builder.Logging.AddProvider(logged); + + builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); + builder.Services.Replace(ServiceDescriptor.Scoped(provider => + new SqlitePasswordResetDbContext(provider.GetRequiredService>()))); + builder.Services.AddIdentity(options => + { + options.User.RequireUniqueEmail = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + builder.Services.AddSingleton(time); + builder.Services.AddSingleton(emailSender ?? sent); + builder.Services.AddDataServices(); + configureServices?.Invoke(builder.Services); + builder.Services.AddBusinessServices(builder.Configuration); + builder.Services.AddControllers() + .AddApplicationPart(typeof(AuthenticationController).Assembly) + .ConfigureApplicationPartManager(manager => + { + manager.FeatureProviders.Clear(); + manager.FeatureProviders.Add(new OnlyAuthenticationController()); + }); + builder.Services.AddPasswordResetRateLimiting(); + + var app = builder.Build(); + app.UseForwardedHeaders(); + app.UseRouting(); + app.UseRateLimiter(); + app.MapControllers(); + + await using (var scope = app.Services.CreateAsyncScope()) + await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); + + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features + .Get()!.Addresses.Single(); + + return new PasswordResetTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) }) + { + Sent = sent, + Time = time, + Logged = logged + }; + } + + public async Task AddUserAsync(string email, string password) + { + await using var scope = _app.Services.CreateAsyncScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser + { + UserName = email, + Email = email, + FirstName = "Alice", + LastName = "Q", + EmailConfirmed = true, + CreatedDate = DateTime.UtcNow + }; + var result = await users.CreateAsync(user, password); + Assert.True(result.Succeeded, string.Join("; ", result.Errors.Select(error => error.Description))); + return user; + } + + public async Task> PendingCodesAsync() + { + await using var scope = _app.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService() + .ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync(); + } + + /// + /// Stores a second pending code for the email directly, as two concurrent first + /// requests could, and returns it. The new row is the newest one. + /// + public async Task AddSiblingCodeAsync(string email, string userId) + { + const string code = "424242"; + const string salt = "0123456789abcdef0123456789abcdef"; + await using var scope = _app.Services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.ForgetPasswordCodes.Add(new ForgetPasswordCode + { + Email = email, + UserId = userId, + CodeSalt = salt, + CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code), + ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15) + }); + await context.SaveChangesAsync(); + return code; + } + + public static HttpRequestMessage Post(string path, object? json = null, string? clientIp = null) + { + var request = new HttpRequestMessage(HttpMethod.Post, path); + if (json != null) + request.Content = JsonContent.Create(json); + if (clientIp != null) + request.Headers.Add("X-Forwarded-For", clientIp); + return request; + } + + public Task ForgetPasswordAsync(string email, string? clientIp = null) => + Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp)); + + public Task VerifyAsync(string email, string code, string? clientIp = null) => + Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp)); + + public Task ResetAsync(string email, string code, string password, string? clientIp = null) => + Client.SendAsync(Post("api/Authentication/ResetPassword", new { email, code, password }, clientIp)); + + public Task LoginAsync(string username, string password) => + Client.SendAsync(Post("api/Authentication/login", new { username, password })); + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + SqliteConnection.ClearAllPools(); + foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" }) + { + if (File.Exists(path)) + File.Delete(path); + } + } + + private sealed class OnlyAuthenticationController : ControllerFeatureProvider + { + protected override bool IsController(System.Reflection.TypeInfo typeInfo) => + typeInfo.AsType() == typeof(AuthenticationController); + } + + private sealed class SqlitePasswordResetDbContext : ApplicationDbContext + { + public SqlitePasswordResetDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + // SQL Server filter syntax does not carry over; a filtered unique index + // without its filter would wrongly reject a second user. + if (index.GetFilter() is not null) + { + index.SetFilter(null); + index.IsUnique = false; + } + } + + foreach (var property in builder.Model.GetEntityTypes() + .SelectMany(entity => entity.GetProperties()) + .Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[]))) + { + property.ValueGenerated = ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +} + +internal sealed class ResetTestClock : TimeProvider +{ + private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now = _now.Add(by); +} + +internal sealed class ResetEmailOutbox : IEmailSender +{ + private static readonly Regex CodePattern = new(@"Your Password Reset Code is: (\d{6})", RegexOptions.CultureInvariant); + + public ConcurrentQueue<(string To, string Subject, string Body)> Messages { get; } = new(); + + public Task SendEmailAsync(string emailTo, string subject, string body) + { + Messages.Enqueue((emailTo, subject, body)); + return Task.FromResult(true); + } + + public string LatestCodeFor(string email) + { + var message = Messages.Last(sent => string.Equals(sent.To, email, StringComparison.OrdinalIgnoreCase)); + return CodePattern.Match(message.Body).Groups[1].Value; + } +} + +internal sealed class ResetLogCapture : ILoggerProvider +{ + public ConcurrentQueue Lines { get; } = new(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName); + + public void Dispose() + { + } + + private sealed class CapturingLogger : ILogger + { + private readonly ResetLogCapture _owner; + private readonly string _category; + + public CapturingLogger(ResetLogCapture owner, string category) + { + _owner = owner; + _category = category; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var values = state is IEnumerable> pairs + ? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}")) + : string.Empty; + _owner.Lines.Enqueue($"{logLevel} {_category} {formatter(state, exception)} {values} {exception}"); + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs index adba1a7..3708240 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs @@ -212,7 +212,7 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests row.Id, created!.Id, new RevokeWorkOrderUpliftRequestDto(), - WorkOrderAccountTestHelpers.AccountUser(), + WorkOrderAccountTestHelpers.AccountUser("actor-1", 1, "Dispatcher"), CancellationToken.None); Assert.Equal("revoked", revoked!.Status); diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs new file mode 100644 index 0000000..d1f3032 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs @@ -0,0 +1,162 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHavenIndustries.Tests; + +// Relational (SQLite) coverage for the live-uplift predicate. The in-memory provider +// evaluates LINQ in memory and cannot prove the "Has uplift" filter or the board uplift +// aggregate translate to SQL, so this runs the advanced search against a real provider, +// scoped to one account, with a live uplift on another account's work order. +public sealed class WorkOrderUpliftLiveStatusRelationalTests +{ + [Fact] + public async Task AdvancedSearch_HasUplift_CountsOnlyLiveUpliftsWithinAccount() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteLiveUpliftTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + var accountA = new Accounts { Name = "Account A" }; + var accountB = new Accounts { Name = "Account B" }; + var vendor = new Vendor { CompanyName = "Acme HVAC", IsActive = true }; + context.AddRange(accountA, accountB, vendor); + await context.SaveChangesAsync(); + + var revokedOnly = NewWorkOrder("WO-REVOKED", accountA.Id); + var cancelledOnly = NewWorkOrder("WO-CANCELLED", accountA.Id); + var revokedPlusPending = NewWorkOrder("WO-MIXED", accountA.Id); + var otherAccountPending = NewWorkOrder("WO-OTHER", accountB.Id); + context.AddRange(revokedOnly, cancelledOnly, revokedPlusPending, otherAccountPending); + await context.SaveChangesAsync(); + + var revokedDispatch = new Dispatch { VendorId = vendor.Id, DispatchNumber = "DIS-R", Status = "Scheduled" }; + var cancelledDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = cancelledOnly.Id, DispatchNumber = "DIS-C", Status = "Scheduled" }; + var mixedDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M", Status = "Scheduled" }; + var mixedPendingDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M2", Status = "Scheduled" }; + var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherAccountPending.Id, DispatchNumber = "DIS-O", Status = "Scheduled" }; + context.Dispatches.AddRange(revokedDispatch, cancelledDispatch, mixedDispatch, mixedPendingDispatch, otherDispatch); + await context.SaveChangesAsync(); + + // The revoked-only work order reaches its uplift through a multi-WO dispatch link. + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = revokedDispatch.Id, WorkOrderId = revokedOnly.Id }); + context.DispatchUpliftRequests.AddRange( + NewUplift(revokedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)), + NewUplift(cancelledDispatch.Id, "Withdrawn", new DateTime(2026, 6, 20, 9, 0, 0)), + NewUplift(mixedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)), + NewUplift(mixedPendingDispatch.Id, "Pending", new DateTime(2026, 6, 20, 11, 0, 0)), + NewUplift(otherDispatch.Id, "Pending", new DateTime(2026, 6, 20, 9, 0, 0))); + await context.SaveChangesAsync(); + + var data = new WorkOrderAdvancedSearchDataService(context); + + var hasUplift = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: null)); + var row = Assert.Single(hasUplift.Rows); + Assert.Equal(revokedPlusPending.Id, row.Id); + Assert.True(row.HasUplift); + Assert.Equal("Pending", row.PrimaryUpliftStatus); + + var revoked = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: new[] { "revoked" })); + Assert.Equal( + new[] { revokedOnly.Id, revokedPlusPending.Id }.OrderBy(id => id), + revoked.Rows.Select(r => r.Id).OrderBy(id => id)); + + var all = await data.SearchAsync(Query(accountA.Id, hasUplift: false, upliftStatuses: null)); + Assert.Equal(3, all.TotalCount); + Assert.DoesNotContain(all.Rows, r => r.Id == otherAccountPending.Id); + Assert.False(all.Rows.Single(r => r.Id == revokedOnly.Id).HasUplift); + Assert.Null(all.Rows.Single(r => r.Id == revokedOnly.Id).PrimaryUpliftStatus); + Assert.False(all.Rows.Single(r => r.Id == cancelledOnly.Id).HasUplift); + Assert.True(all.Rows.Single(r => r.Id == revokedPlusPending.Id).HasUplift); + } + + private static WorkOrder NewWorkOrder(string number, int accountId) => new() + { + InternalWONumber = number, + WorkerOrderTitle = "Repair", + AccountId = accountId, + ScheduledDate = new DateTime(2026, 6, 23, 8, 0, 0), + LifecycleStatus = LifecycleStatus.Scheduled, + }; + + private static DispatchUpliftRequest NewUplift(int dispatchId, string status, DateTime createdDate) => new() + { + DispatchId = dispatchId, + Status = status, + CreatedDate = createdDate, + RequiredTier = 1, + RequestedNTE = 750m, + NotificationStatus = "Pending", + }; + + private static WorkOrderAdvancedSearchQuery Query( + int accountId, + bool hasUplift, + IReadOnlyList? upliftStatuses) => new( + Search: null, + DateFrom: new DateOnly(2026, 6, 22), + DateTo: new DateOnly(2026, 6, 28), + UnscheduledOnly: false, + Sites: null, + Regions: null, + Types: null, + Overdue: false, + Dispatchers: null, + Statuses: null, + PmTypes: null, + VendorIds: null, + DocStatuses: null, + Severities: null, + Rescheduled: false, + CarriedOver: false, + AddOn: false, + AvetaOnly: false, + FlagColors: null, + InternalOnly: false, + HasUplift: hasUplift, + UpliftStatuses: upliftStatuses, + MyWorkOrders: false, + CurrentUserId: null, + Page: 0, + PageSize: 50, + SortBy: "scheduledDate", + SortDir: "asc", + AccountId: accountId); + + private sealed class SqliteLiveUpliftTestDbContext : ApplicationDbContext + { + public SqliteLiveUpliftTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // SQL Server filtered index syntax is invalid on SQLite. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + // SQLite has no rowversion type; treat as plain nullable blobs. + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs new file mode 100644 index 0000000..0370751 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs @@ -0,0 +1,266 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +/// +/// "Has uplift" (advanced filter) and the board Uplift column count only live uplifts: +/// a work order whose uplifts were all cancelled, withdrawn, expired or revoked has none. +/// +public class WorkOrderUpliftLiveStatusTests +{ + private static readonly DateOnly WeekStart = new(2026, 6, 22); + + private const int WithdrawnOnly = 1; + private const int LegacyCancelledOnly = 2; + private const int RevokedOnlyViaLink = 3; + private const int RevokedPlusPending = 4; + private const int RejectedOnly = 5; + private const int ExpiredOnly = 6; + private const int NoUplift = 7; + private const int NewerRevokedOlderAutoApproved = 8; + + private static readonly int[] LiveUpliftWorkOrders = + { + RevokedPlusPending, + RejectedOnly, + NewerRevokedOlderAutoApproved, + }; + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedAsync() + { + var context = CreateContext(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + + for (var id = WithdrawnOnly; id <= NewerRevokedOlderAutoApproved; id++) + { + // The revoked-only work order reaches its uplift through a multi-WO dispatch link. + var linkedOnly = id == RevokedOnlyViaLink; + context.Dispatches.Add(new Dispatch + { + Id = 10 + id, + VendorId = 1, + WorkOrderId = linkedOnly ? null : id, + }); + if (linkedOnly) + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10 + id, WorkOrderId = id }); + + context.workOrders.Add(new WorkOrder + { + Id = id, + InternalWONumber = $"1000000000{id}", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = linkedOnly ? null : 10 + id, + }); + } + + var older = new DateTime(2026, 6, 20, 9, 0, 0, DateTimeKind.Utc); + var newer = older.AddHours(2); + context.DispatchUpliftRequests.AddRange( + Uplift(101, WithdrawnOnly, "Withdrawn", older), + Uplift(102, LegacyCancelledOnly, "Cancelled", older), + Uplift(103, RevokedOnlyViaLink, "Revoked", older), + Uplift(104, RevokedPlusPending, "Revoked", older), + Uplift(105, RevokedPlusPending, "Pending", newer), + Uplift(106, RejectedOnly, "Rejected", older), + Uplift(107, ExpiredOnly, "Expired", older), + Uplift(108, NewerRevokedOlderAutoApproved, "NoApprovalRequired", older), + Uplift(109, NewerRevokedOlderAutoApproved, "Revoked", newer)); + + await context.SaveChangesAsync(); + return context; + } + + private static DispatchUpliftRequest Uplift(int id, int workOrderId, string status, DateTime createdDate) => new() + { + Id = id, + DispatchId = 10 + workOrderId, + RequestedNTE = 1000m + id, + Status = status, + RequiredTier = 1, + NotificationStatus = "Pending", + CreatedDate = createdDate, + }; + + private static async Task SearchIdsAsync( + ApplicationDbContext context, + bool hasUplift, + List? upliftStatuses = null) + { + var service = new WorkOrderAdvancedSearchService( + new WorkOrderAdvancedSearchDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync( + new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = WeekStart, + DateTo = WeekStart.AddDays(6), + HasUplift = hasUplift, + UpliftStatuses = upliftStatuses, + PageSize = 50, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + return result.Items.Select(i => i.Id).OrderBy(id => id).ToArray(); + } + + [Fact] + public async Task HasUplift_ExcludesWorkOrdersWhoseOnlyUpliftsAreCancelledOrRevoked() + { + await using var context = await SeedAsync(); + + var ids = await SearchIdsAsync(context, hasUplift: true); + + Assert.Equal(LiveUpliftWorkOrders, ids); + Assert.DoesNotContain(WithdrawnOnly, ids); + Assert.DoesNotContain(LegacyCancelledOnly, ids); + Assert.DoesNotContain(RevokedOnlyViaLink, ids); + Assert.DoesNotContain(ExpiredOnly, ids); + } + + [Fact] + public async Task HasUplift_IncludesWorkOrderWithRevokedAndPendingUplift() + { + await using var context = await SeedAsync(); + + Assert.Contains(RevokedPlusPending, await SearchIdsAsync(context, hasUplift: true)); + Assert.Equal( + new[] { RevokedPlusPending }, + await SearchIdsAsync(context, hasUplift: true, new List { "pending" })); + } + + [Fact] + public async Task HasUplift_RejectedUpliftStillCounts() + { + await using var context = await SeedAsync(); + + Assert.Equal( + new[] { RejectedOnly }, + await SearchIdsAsync(context, hasUplift: true, new List { "rejected" })); + } + + [Fact] + public async Task HasUplift_ExplicitCancelledStatus_FindsCancelledWithdrawnAndExpiredUplifts() + { + await using var context = await SeedAsync(); + + var ids = await SearchIdsAsync(context, hasUplift: true, new List { "cancelled" }); + + Assert.Equal(new[] { WithdrawnOnly, LegacyCancelledOnly, ExpiredOnly }, ids); + } + + [Fact] + public async Task HasUplift_ExplicitRevokedStatus_FindsRevokedUpliftsOnPrimaryAndLinkedDispatches() + { + await using var context = await SeedAsync(); + + var ids = await SearchIdsAsync(context, hasUplift: true, new List { "revoked" }); + + Assert.Equal(new[] { RevokedOnlyViaLink, RevokedPlusPending, NewerRevokedOlderAutoApproved }, ids); + } + + [Fact] + public async Task BoardUpliftColumn_AgreesWithHasUpliftFilter() + { + await using var context = await SeedAsync(); + var filtered = await SearchIdsAsync(context, hasUplift: true); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var board = await boardService.GetBoardAsync( + new WorkOrderBoardQueryDto { WeekStart = WeekStart }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(8, board.Scheduled.Count); + foreach (var row in board.Scheduled) + { + Assert.Equal(filtered.Contains(row.Id), row.UpliftSummary!.HasUplift); + } + + var searchService = new WorkOrderAdvancedSearchService( + new WorkOrderAdvancedSearchDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var unfiltered = await searchService.SearchAsync( + new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = WeekStart, + DateTo = WeekStart.AddDays(6), + PageSize = 50, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + Assert.Equal(8, unfiltered.Items.Count()); + foreach (var item in unfiltered.Items) + { + Assert.Equal(filtered.Contains(item.Id), item.UpliftSummary!.HasUplift); + } + } + + [Fact] + public async Task BoardUpliftColumn_ShowsNewestLiveUpliftAndNoStatusForNonLiveOnly() + { + await using var context = await SeedAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var board = await boardService.GetBoardAsync( + new WorkOrderBoardQueryDto { WeekStart = WeekStart }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + var byId = board.Scheduled.ToDictionary(r => r.Id, r => r.UpliftSummary!); + + foreach (var id in new[] { WithdrawnOnly, LegacyCancelledOnly, RevokedOnlyViaLink, ExpiredOnly, NoUplift }) + { + Assert.False(byId[id].HasUplift); + Assert.Null(byId[id].PrimaryStatus); + Assert.Null(byId[id].Amount); + } + + Assert.Equal("pending", byId[RevokedPlusPending].PrimaryStatus); + Assert.Equal(1, byId[RevokedPlusPending].PendingCount); + Assert.Equal("rejected", byId[RejectedOnly].PrimaryStatus); + Assert.Equal("auto_approved", byId[NewerRevokedOlderAutoApproved].PrimaryStatus); + Assert.Equal(1108m, byId[NewerRevokedOlderAutoApproved].Amount); + } + + [Theory] + [InlineData(UpliftStatus.Pending)] + [InlineData(UpliftStatus.Approved)] + [InlineData(UpliftStatus.Rejected)] + [InlineData(UpliftStatus.ChangesRequested)] + [InlineData(UpliftStatus.Withdrawn)] + [InlineData(UpliftStatus.Expired)] + [InlineData(UpliftStatus.NoApprovalRequired)] + [InlineData(UpliftStatus.Revoked)] + [InlineData(UpliftStatus.LegacyDenied)] + [InlineData(UpliftStatus.LegacyCancelled)] + public void LiveUpliftStatus_MatchesFrontendCancelledAndRevokedStatuses(string storedStatus) + { + var frontendStatus = WorkOrderUpliftContractMapper.ToFrontendStatus(storedStatus); + var expectedLive = frontendStatus is not ("cancelled" or "revoked"); + + Assert.Equal(expectedLive, LiveUpliftStatus.IsLive(storedStatus)); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 390beaf..9685f70 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -48,6 +48,11 @@ public sealed class WorkOrderUpliftServiceTests private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1") => WorkOrderAccountTestHelpers.OrgWideAdmin(userId); + // Same user as Dispatcher(), without the Admin role: the only kind of caller that + // may revoke an auto-approved uplift, and only their own. + private static ClaimsPrincipal DispatcherRoleOnly(string userId = "dispatcher-1") + => WorkOrderAccountTestHelpers.AccountUser(userId, 1, "Dispatcher"); + private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( ApplicationDbContext context, WorkOrderType type = WorkOrderType.PM, @@ -585,7 +590,7 @@ public sealed class WorkOrderUpliftServiceTests workOrder.Id, created.Id, new RevokeWorkOrderUpliftRequestDto(), - Dispatcher(), + DispatcherRoleOnly(), CancellationToken.None); // SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise @@ -724,7 +729,7 @@ public sealed class WorkOrderUpliftServiceTests workOrder.Id, created!.Id, new RevokeWorkOrderUpliftRequestDto(), - Dispatcher(), + DispatcherRoleOnly(), CancellationToken.None); Assert.Equal("revoked", revoked!.Status); @@ -733,6 +738,35 @@ public sealed class WorkOrderUpliftServiceTests .SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None)); } + [Fact] + public async Task RevokeAsync_AdminOwnerRevokingAutoApproved_IsForbiddenAndKeepsAllowanceConsumed() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Dispatcher(), + CancellationToken.None); + Assert.Equal("auto_approved", created!.Status); + + await Assert.ThrowsAsync(() => service.RevokeAsync( + workOrder.Id, + created.Id, + new RevokeWorkOrderUpliftRequestDto { Reason = "Wrong quote" }, + Dispatcher(), + CancellationToken.None)); + + var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id); + Assert.Equal(UpliftStatus.NoApprovalRequired, stored.Status); + Assert.Null(stored.DecisionNote); + Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + Assert.Equal(400m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None)); + } + [Theory] [InlineData(LifecycleStatus.Completed)] [InlineData(LifecycleStatus.Canceled)] @@ -760,7 +794,7 @@ public sealed class WorkOrderUpliftServiceTests workOrder.Id, 100, new RevokeWorkOrderUpliftRequestDto(), - Dispatcher(), + DispatcherRoleOnly(), CancellationToken.None)); Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); }