diff --git a/Api.SeaHavenIndustries.Tests/SendMessageTests.cs b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs new file mode 100644 index 0000000..806bcd1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using Api.SeaHavenIndustries.Helper; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using SendGrid; +using SendGrid.Helpers.Mail; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class SendMessageTests +{ + private const string Recipient = "taylor@example.com"; + private const string Subject = "You're invited to Seahaven"; + private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}"; + + [Theory] + [InlineData(HttpStatusCode.OK, true)] + [InlineData(HttpStatusCode.Accepted, true)] + [InlineData(HttpStatusCode.BadRequest, false)] + [InlineData(HttpStatusCode.Unauthorized, false)] + [InlineData(HttpStatusCode.TooManyRequests, false)] + [InlineData(HttpStatusCode.InternalServerError, false)] + public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered) + { + var sender = new StubbedSendMessage(status, new CapturingLogger()); + + Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "

Hi

")); + Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "

Hi

")); + Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 })); + Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "

Hi

", null)); + } + + [Fact] + public async Task ARejectedSend_LogsOnlyTheStatusCode() + { + var logger = new CapturingLogger(); + var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger); + + Assert.False(await sender.SendEMail(Recipient, Subject, "

secret link

")); + + var entry = Assert.Single(logger.Entries); + Assert.Contains("400", entry); + Assert.DoesNotContain(Recipient, entry); + Assert.DoesNotContain(Subject, entry); + Assert.DoesNotContain("secret link", entry); + Assert.DoesNotContain("errors", entry); + } + + [Fact] + public async Task AFailedSend_LogsOnlyTheExceptionType() + { + var logger = new CapturingLogger(); + var sender = new StubbedSendMessage( + new HttpRequestException($"could not reach SendGrid for {Recipient}"), + logger); + + Assert.False(await sender.SendEMail(Recipient, Subject, "

Hi

")); + + var entry = Assert.Single(logger.Entries); + Assert.Contains(nameof(HttpRequestException), entry); + Assert.DoesNotContain(Recipient, entry); + } + + private sealed class StubbedSendMessage : SendMessage + { + private readonly Mock _client = new(); + + public StubbedSendMessage(HttpStatusCode status, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null)); + } + + public StubbedSendMessage(Exception failure, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(failure); + } + + protected override ISendGridClient CreateClient(string? apiKey) => _client.Object; + } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = new(); + + 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) => + Entries.Add($"{formatter(state, exception)} {exception}"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs new file mode 100644 index 0000000..d032105 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs @@ -0,0 +1,171 @@ +using System.Reflection; +using System.Text.Json; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging.Abstractions; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class TeamMemberInviteControllerTests +{ + [Fact] + public void RegistrationRequests_CarryNoUserIdentifier() + { + var requestTypes = new[] + { + typeof(TeamMemberInviteTokenRequestDTO), + typeof(VerifyTeamMemberInviteCodeRequestDTO), + typeof(CompleteTeamMemberRegistrationRequestDTO) + }; + + var properties = requestTypes + .SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .Select(property => property.Name) + .Distinct() + .OrderBy(name => name); + + properties.Should().Equal("Code", "Password", "Phone", "Token"); + } + + [Fact] + public void RegistrationEndpoints_AreAnonymousAndNeverCached() + { + var type = typeof(TeamMemberInviteController); + + type.GetCustomAttribute().Should().NotBeNull(); + var cache = type.GetCustomAttribute(); + cache.Should().NotBeNull(); + cache!.NoStore.Should().BeTrue(); + } + + [Fact] + public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter() + { + var filter = typeof(TeamMemberInviteController).GetCustomAttribute(); + + filter.Should().NotBeNull(); + filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter)); + } + + [Fact] + public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail() + { + var context = ExceptionContextFor(new ArgumentException( + "SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'")); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeTrue(); + var result = context.Result.Should().BeOfType().Subject; + result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + var body = JsonSerializer.Serialize(result.Value); + body.Should().Be( + "{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}"); + body.Should().NotContain("SELECT").And.NotContain("leak-me"); + } + + [Fact] + public void ExceptionFilter_LeavesCancellationToTheHost() + { + var context = ExceptionContextFor(new OperationCanceledException()); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeFalse(); + context.Result.Should().BeNull(); + } + + private static ExceptionContext ExceptionContextFor(Exception exception) + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + return new ExceptionContext(actionContext, new List()) { Exception = exception }; + } + + [Theory] + [InlineData(TeamMemberRegistrationStatus.InvalidInvite)] + [InlineData(TeamMemberRegistrationStatus.Ok)] + public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status) + { + var service = new Mock(); + service.Setup(x => x.CompleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status }); + var controller = new TeamMemberInviteController(service.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None); + + var failure = result.Should().BeOfType().Subject; + failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + failure.Value.Should().BeEquivalentTo(new + { + code = "invalid_invite", + message = TeamMemberInviteController.InvalidInviteMessage + }); + } + + [Fact] + public void ResendInvite_RequiresAuthenticatedCaller() + { + typeof(TeamMemberController).GetCustomAttribute().Should().NotBeNull(); + typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))! + .GetCustomAttribute().Should().BeNull(); + } + + [Fact] + public async Task ResendInvite_Success_ReturnsInviteSent() + { + var invites = new Mock(); + invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true }); + + var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); + + result.Should().BeOfType().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" }); + } + + [Theory] + [InlineData("Forbidden", typeof(ForbidResult))] + [InlineData("Team member not found.", typeof(NotFoundObjectResult))] + [InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))] + [InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))] + public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected) + { + var invites = new Mock(); + invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error }); + + var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); + + result.Should().BeOfType(expected); + } + + private static TeamMemberController NewTeamMemberController(Mock invites) + { + return new TeamMemberController(Mock.Of(), Mock.Of(), invites.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test")) + } + } + }; + } +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs index b41e2d8..83416b6 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs @@ -85,7 +85,10 @@ public sealed class TeamMemberPermissionsEndpointTests private static TeamMemberController Controller( ITeamPermissionOverrideDataService data, ClaimsPrincipal user) => - new(Mock.Of(), new TeamPermissionService(data, new TeamPermissionPolicy())) + new( + Mock.Of(), + new TeamPermissionService(data, new TeamPermissionPolicy()), + Mock.Of()) { ControllerContext = new ControllerContext { diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index cd2ced8..3297fb1 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -28,7 +28,8 @@ public sealed class TeamMemberServiceTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); var result = await service.CreateAsync( ValidRequest() with { ServiceAreas = Array.Empty() }, @@ -408,7 +409,8 @@ public sealed class TeamMemberServiceTests areas.Object, overrides.Object, userData.Object, - permissions.Object); + permissions.Object, + Mock.Of()); } private static Mock> UserManager() diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index dcd4dfd..efccb2b 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -33,16 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken); if (result != null) { - return Ok(new - { - token = result.Token, - expiration = result.Expiration, - email = result.Email, - userRoles = result.UserRole, - phoneNumber = result.PhoneNumber, - fullname = result.Fullname, - id = result.Id - }); + return Ok(LoginPayload.From(result)); } return Unauthorized(); diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs index ad2bfca..24bae6d 100644 --- a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -12,13 +12,16 @@ public sealed class TeamMemberController : ControllerBase { private readonly ITeamMemberService _teamMemberService; private readonly ITeamPermissionService _permissionService; + private readonly ITeamMemberInviteService _inviteService; public TeamMemberController( ITeamMemberService teamMemberService, - ITeamPermissionService permissionService) + ITeamPermissionService permissionService, + ITeamMemberInviteService inviteService) { _teamMemberService = teamMemberService; _permissionService = permissionService; + _inviteService = inviteService; } [HttpGet("me/permissions")] @@ -28,6 +31,21 @@ public sealed class TeamMemberController : ControllerBase return result.IsSuccess ? Ok(result.Value) : Unauthorized(); } + [HttpPost("{userId}/invite")] + public async Task ResendInvite(string userId, CancellationToken cancellationToken) + { + var outcome = await _inviteService.ResendAsync(userId, User, cancellationToken); + if (outcome.Success) + return Ok(new { message = "Invite sent" }); + + return outcome.Error switch + { + "Forbidden" => Forbid(), + "Team member not found." => NotFound(new { message = outcome.Error }), + _ => BadRequest(new { message = outcome.Error }) + }; + } + [HttpPost] public async Task Create( CreateTeamMemberRequestDTO request, diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs new file mode 100644 index 0000000..af16db7 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs @@ -0,0 +1,95 @@ +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +/// +/// Anonymous invite registration. The invite token travels only in request bodies, +/// and every failure maps to a fixed public message. +/// +[AllowAnonymous] +[ApiController] +[Route("api/team-member-invites")] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +[TypeFilter(typeof(InviteRegistrationExceptionFilter))] +public sealed class TeamMemberInviteController : ControllerBase +{ + public const string InvalidInviteMessage = + "This invite link is invalid or has expired. Ask your admin to send a new invite."; + + private readonly ITeamMemberRegistrationService _registrationService; + + public TeamMemberInviteController(ITeamMemberRegistrationService registrationService) + { + _registrationService = registrationService; + } + + [HttpPost("resolve")] + public async Task Resolve(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.ResolveAsync(request.Token, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok ? Ok(outcome.Details) : Failure(outcome); + } + + [HttpPost("send-code")] + public async Task SendCode(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.SendCodeAsync(request.Token, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok + ? Ok(new { message = "Code sent" }) + : Failure(outcome); + } + + [HttpPost("verify-code")] + public async Task VerifyCode(VerifyTeamMemberInviteCodeRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.VerifyCodeAsync(request.Token, request.Code, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok + ? Ok(new { message = "Email confirmed" }) + : Failure(outcome); + } + + [HttpPost("complete")] + public async Task Complete(CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.CompleteAsync(request, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok && outcome.Session is not null + ? Ok(LoginPayload.From(outcome.Session)) + : Failure(outcome); + } + + private IActionResult Failure(TeamMemberRegistrationOutcomeDTO outcome) + { + var (statusCode, code, message) = outcome.Status switch + { + TeamMemberRegistrationStatus.CodeIncorrect => + (StatusCodes.Status400BadRequest, "code_incorrect", "Incorrect code — check your email and try again"), + TeamMemberRegistrationStatus.CodeExpired => + (StatusCodes.Status400BadRequest, "code_expired", "This code has expired — request a new code"), + TeamMemberRegistrationStatus.CodeLocked => + (StatusCodes.Status400BadRequest, "code_locked", "Too many incorrect attempts — request a new code"), + TeamMemberRegistrationStatus.ResendTooSoon => + (StatusCodes.Status429TooManyRequests, "resend_too_soon", "Please wait a moment before requesting another code"), + TeamMemberRegistrationStatus.ResendLimitReached => + (StatusCodes.Status429TooManyRequests, "resend_limit_reached", "Too many codes were requested. Ask your admin to send a new invite."), + TeamMemberRegistrationStatus.CodeDeliveryFailed => + (StatusCodes.Status503ServiceUnavailable, "code_delivery_failed", "We couldn't send the code. Try again in a minute."), + TeamMemberRegistrationStatus.EmailNotConfirmed => + (StatusCodes.Status400BadRequest, "email_not_confirmed", "Confirm your email before finishing registration"), + TeamMemberRegistrationStatus.PasswordRejected => + (StatusCodes.Status400BadRequest, "password_rejected", "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."), + TeamMemberRegistrationStatus.InvalidPhone => + (StatusCodes.Status400BadRequest, "invalid_phone", "Enter a valid phone number"), + _ => (StatusCodes.Status400BadRequest, "invalid_invite", InvalidInviteMessage) + }; + + if (outcome.RetryAfterSeconds is int retryAfter) + Response.Headers.RetryAfter = retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture); + + return StatusCode(statusCode, new { code, message, retryAfterSeconds = outcome.RetryAfterSeconds }); + } +} diff --git a/Api.SeaHavenIndustries/DTOs/LoginPayload.cs b/Api.SeaHavenIndustries/DTOs/LoginPayload.cs new file mode 100644 index 0000000..9e13416 --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/LoginPayload.cs @@ -0,0 +1,24 @@ +using SeaHaven.Services.DTOs; + +namespace Api.SeaHavenIndustries.DTOs +{ + /// The session payload the web app stores after sign-in. + public static class LoginPayload + { + public static object From(LoginResultDTO result) + { + ArgumentNullException.ThrowIfNull(result); + + return new + { + token = result.Token, + expiration = result.Expiration, + email = result.Email, + userRoles = result.UserRole, + phoneNumber = result.PhoneNumber, + fullname = result.Fullname, + id = result.Id + }; + } + } +} diff --git a/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs new file mode 100644 index 0000000..93b9b00 --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace Api.SeaHavenIndustries.Filters +{ + /// + /// Anonymous invite endpoints never echo an exception: controller-scoped exception + /// filters run before the global ones, so no message, stack or SQL reaches the caller. + /// Cancellation is left to the host. + /// + public sealed class InviteRegistrationExceptionFilter : IExceptionFilter + { + public const string Message = "Something went wrong. Try again in a minute."; + + private readonly ILogger _logger; + + public InviteRegistrationExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + if (context.Exception is OperationCanceledException) + return; + + _logger.LogError(context.Exception, "Invite registration request failed."); + + context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index 08db640..5bab7ee 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper public class SendMessage : IEmailSender { private IConfiguration _configuration; + private readonly ILogger _logger; //string keysapi = ""; - public SendMessage(IConfiguration configuration) + public SendMessage(IConfiguration configuration, ILogger logger) { _configuration = configuration; + _logger = logger; //keysapi = _configuration.GetValue("SendGrid:ApiKey"); } + protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey); + + /// + /// SendGrid reports a rejected message through the status code, not an exception. + /// Only the status is logged: never the recipient, subject or response body. + /// + private bool Delivered(Response response) + { + if (response.IsSuccessStatusCode) + return true; + + _logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode); + return false; + } + + private bool Failed(Exception ex) + { + _logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name); + return false; + } + public async Task SendEMail(string emailTo, string subject, string body) { try @@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; var to = new EmailAddress(emailTo, ""); @@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper var htmlContent = body; var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent); var response = await client.SendEmailAsync(msg); - var dd = response.Body.ReadAsStringAsync(); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } public async Task SendEmailAsync(string emailTo, string subject, string htmlBody) @@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; var to = new EmailAddress(emailTo, ""); @@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper msg.Attachments = new List { attachment }; var response = await client.SendEmailAsync(msg); - - var dd = response.Body.ReadAsStringAsync(); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } public async Task SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo) @@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper try { var apiKey = _configuration.GetValue("SendGrid:ApiKey"); - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries"); var to = new EmailAddress(emailTo, ""); @@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper msg.SetReplyTo(new EmailAddress(replyTo)); var response = await client.SendEmailAsync(msg); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } } diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index eec5ec0..f60dd32 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -328,6 +328,21 @@ namespace Data.SeaHavenIndustries .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); }); + builder.Entity(entity => + { + entity.HasIndex(invite => invite.TokenHash) + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + entity.HasIndex(invite => invite.UserId) + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + entity.HasOne(invite => invite.User) + .WithMany() + .HasForeignKey(invite => invite.UserId) + .OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => { entity.HasKey(area => new { area.UserId, area.Area }); @@ -406,6 +421,7 @@ namespace Data.SeaHavenIndustries public DbSet Regions { get; set; } public DbSet UserPermissionOverrides { get; set; } public DbSet UserServiceAreas { get; set; } + public DbSet TeamMemberInvites { get; set; } public override int SaveChanges() { diff --git a/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs new file mode 100644 index 0000000..0375458 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs @@ -0,0 +1,4289 @@ +// +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("20260925142034_AddTeamMemberInvites")] + partial class AddTeamMemberInvites + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsAccountOwner") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingRegistration") + .HasColumnType("bit"); + + b.Property("PendingRegistrationCreatedDate") + .HasColumnType("datetime2"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("IsAccountOwner") + .IsUnique() + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner") + .HasFilter("IsAccountOwner = 1"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CodeExpiresAt") + .HasColumnType("datetime2"); + + b.Property("CodeFailedAttempts") + .HasColumnType("int"); + + b.Property("CodeHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("CodeSendCount") + .HasColumnType("int"); + + b.Property("CodeSentAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + b.ToTable("TeamMemberInvites"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ServiceId"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.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.TeamMemberInvite", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("ServiceAreas"); + + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.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/20260925142034_AddTeamMemberInvites.cs b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.cs new file mode 100644 index 0000000..22ee2d0 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class AddTeamMemberInvites : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TeamMemberInvites", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TokenHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + UsedAt = table.Column(type: "datetime2", nullable: true), + RevokedAt = table.Column(type: "datetime2", nullable: true), + CodeHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + CodeSalt = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: true), + CodeExpiresAt = table.Column(type: "datetime2", nullable: true), + CodeFailedAttempts = table.Column(type: "int", nullable: false), + CodeSentAt = table.Column(type: "datetime2", nullable: true), + CodeSendCount = table.Column(type: "int", nullable: false), + EmailConfirmedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMemberInvites", x => x.Id); + table.ForeignKey( + name: "FK_TeamMemberInvites_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_TeamMemberInvites_TokenHash", + table: "TeamMemberInvites", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TeamMemberInvites_UserId", + table: "TeamMemberInvites", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TeamMemberInvites"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 488a058..7e3f322 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2104,6 +2104,71 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("TaskListTemplateItems"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CodeExpiresAt") + .HasColumnType("datetime2"); + + b.Property("CodeFailedAttempts") + .HasColumnType("int"); + + b.Property("CodeHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("CodeSendCount") + .HasColumnType("int"); + + b.Property("CodeSentAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + b.ToTable("TeamMemberInvites"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => { b.Property("Id") @@ -3833,6 +3898,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Template"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => { b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") diff --git a/Data.SeaHavenIndustries/Models/TeamMemberInvite.cs b/Data.SeaHavenIndustries/Models/TeamMemberInvite.cs new file mode 100644 index 0000000..29d2f16 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/TeamMemberInvite.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + /// + /// A single-use registration invite for one pending team member. Only hashes of + /// the invite token and of the email confirmation code are stored. + /// + public class TeamMemberInvite + { + public int Id { get; set; } + + [Required] + [MaxLength(450)] + public string UserId { get; set; } = string.Empty; + + [ForeignKey(nameof(UserId))] + public virtual ApplicationUser? User { get; set; } + + /// Lowercase hex SHA-256 of the raw invite token. + [Required] + [MaxLength(64)] + public string TokenHash { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? UsedAt { get; set; } + public DateTime? RevokedAt { get; set; } + + /// Lowercase hex SHA-256 of the salt followed by the confirmation code. + [MaxLength(64)] + public string? CodeHash { get; set; } + + [MaxLength(32)] + public string? CodeSalt { get; set; } + + public DateTime? CodeExpiresAt { get; set; } + public int CodeFailedAttempts { get; set; } + public DateTime? CodeSentAt { get; set; } + public int CodeSendCount { get; set; } + public DateTime? EmailConfirmedAt { get; set; } + } +} diff --git a/SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs b/SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs new file mode 100644 index 0000000..551fc50 --- /dev/null +++ b/SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs @@ -0,0 +1,36 @@ +namespace SeaHaven.DataServices.Dto +{ + public sealed class TeamMemberInviteData + { + public required int Id { get; init; } + public required string UserId { get; init; } + public DateTime ExpiresAt { get; init; } + public DateTime? UsedAt { get; init; } + public DateTime? RevokedAt { get; init; } + public string? CodeHash { get; init; } + public string? CodeSalt { get; init; } + public DateTime? CodeExpiresAt { get; init; } + public int CodeFailedAttempts { get; init; } + public DateTime? CodeSentAt { get; init; } + public int CodeSendCount { get; init; } + public DateTime? EmailConfirmedAt { get; init; } + } + + /// + /// Replaces the confirmation code on an open invite when the resend cooldown has + /// elapsed and the send limit has not been reached. + /// + public sealed class StartTeamMemberInviteCodeCommand + { + public required int InviteId { get; init; } + public required string CodeHash { get; init; } + public required string CodeSalt { get; init; } + public required DateTime Now { get; init; } + public required DateTime CodeExpiresAt { get; init; } + + /// A code may be replaced only when the previous one was sent at or before this instant. + public required DateTime LastSentNoLaterThan { get; init; } + + public required int MaxSends { get; init; } + } +} diff --git a/SeaHaven.DataServices/Implementation/TeamMemberInviteDataService.cs b/SeaHaven.DataServices/Implementation/TeamMemberInviteDataService.cs new file mode 100644 index 0000000..4ddbd01 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/TeamMemberInviteDataService.cs @@ -0,0 +1,144 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class TeamMemberInviteDataService : ITeamMemberInviteDataService + { + private readonly ApplicationDbContext _context; + + public TeamMemberInviteDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(invite); + + _context.TeamMemberInvites.Add(invite); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ReplaceOpenForUserAsync( + TeamMemberInvite invite, + DateTime now, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(invite); + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + await _context.TeamMemberInvites + .Where(existing => existing.UserId == invite.UserId + && existing.UsedAt == null + && existing.RevokedAt == null) + .ExecuteUpdateAsync( + setters => setters.SetProperty(existing => existing.RevokedAt, now), + cancellationToken); + _context.TeamMemberInvites.Add(invite); + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + public Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) + { + return _context.TeamMemberInvites + .AsNoTracking() + .Where(invite => invite.TokenHash == tokenHash) + .Select(invite => new TeamMemberInviteData + { + Id = invite.Id, + UserId = invite.UserId, + ExpiresAt = invite.ExpiresAt, + UsedAt = invite.UsedAt, + RevokedAt = invite.RevokedAt, + CodeHash = invite.CodeHash, + CodeSalt = invite.CodeSalt, + CodeExpiresAt = invite.CodeExpiresAt, + CodeFailedAttempts = invite.CodeFailedAttempts, + CodeSentAt = invite.CodeSentAt, + CodeSendCount = invite.CodeSendCount, + EmailConfirmedAt = invite.EmailConfirmedAt + }) + .SingleOrDefaultAsync(cancellationToken); + } + + public async Task TryStartCodeAsync( + StartTeamMemberInviteCodeCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var updated = await OpenInvite(command.InviteId, command.Now) + .Where(invite => invite.CodeSendCount < command.MaxSends + && (invite.CodeSentAt == null || invite.CodeSentAt <= command.LastSentNoLaterThan)) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(invite => invite.CodeHash, command.CodeHash) + .SetProperty(invite => invite.CodeSalt, command.CodeSalt) + .SetProperty(invite => invite.CodeExpiresAt, command.CodeExpiresAt) + .SetProperty(invite => invite.CodeFailedAttempts, 0) + .SetProperty(invite => invite.CodeSentAt, command.Now) + .SetProperty(invite => invite.CodeSendCount, invite => invite.CodeSendCount + 1), + cancellationToken); + return updated == 1; + } + + public async Task TryReserveCodeAttemptAsync( + int inviteId, + int maxAttempts, + DateTime now, + CancellationToken cancellationToken) + { + var updated = await OpenInvite(inviteId, now) + .Where(invite => invite.CodeHash != null + && invite.CodeExpiresAt > now + && invite.CodeFailedAttempts < maxAttempts) + .ExecuteUpdateAsync( + setters => setters.SetProperty( + invite => invite.CodeFailedAttempts, + invite => invite.CodeFailedAttempts + 1), + cancellationToken); + return updated == 1; + } + + public async Task TryConfirmEmailAsync( + int inviteId, + string codeHash, + DateTime now, + CancellationToken cancellationToken) + { + var updated = await OpenInvite(inviteId, now) + .Where(invite => invite.CodeHash == codeHash && invite.CodeExpiresAt > now) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(invite => invite.EmailConfirmedAt, now) + .SetProperty(invite => invite.CodeHash, (string?)null) + .SetProperty(invite => invite.CodeSalt, (string?)null) + .SetProperty(invite => invite.CodeExpiresAt, (DateTime?)null) + .SetProperty(invite => invite.CodeFailedAttempts, 0), + cancellationToken); + return updated == 1; + } + + public async Task TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) + { + var updated = await OpenInvite(inviteId, now) + .Where(invite => invite.EmailConfirmedAt != null) + .ExecuteUpdateAsync( + setters => setters.SetProperty(invite => invite.UsedAt, now), + cancellationToken); + return updated == 1; + } + + private IQueryable OpenInvite(int inviteId, DateTime now) + { + return _context.TeamMemberInvites.Where(invite => invite.Id == inviteId + && invite.UsedAt == null + && invite.RevokedAt == null + && invite.ExpiresAt > now); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 32ac851..dce7af1 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -159,6 +159,10 @@ namespace SeaHaven.DataServices.Implementation .Where(permissionOverride => permissionOverride.UserId == id) .ExecuteDeleteAsync(cancellationToken); + await _context.TeamMemberInvites + .Where(invite => invite.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + await _context.Users .Where(existingUser => existingUser.Id == id) .ExecuteDeleteAsync(cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/ITeamMemberInviteDataService.cs b/SeaHaven.DataServices/Interfaces/ITeamMemberInviteDataService.cs new file mode 100644 index 0000000..4140f16 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ITeamMemberInviteDataService.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; + +namespace SeaHaven.DataServices.Interfaces +{ + /// + /// Persistence for team member registration invites. Every state transition that + /// guards a security limit is a single conditional update, so concurrent requests + /// cannot exceed the limit; each returns whether the transition happened. + /// + public interface ITeamMemberInviteDataService + { + Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken); + + /// Revokes every open invite for the invite's user and adds the new one, atomically. + Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken); + + Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken); + + Task TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken); + + /// Counts one verification attempt against a live code; false once the attempt limit is used up. + Task TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken); + + Task TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken); + + /// Marks an open, unexpired, email-confirmed invite as used; false when another request already did. + Task TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs index 814e279..e0b8e31 100644 --- a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs +++ b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Data.SeaHavenIndustries.Enums; namespace SeaHaven.Services.DTOs; @@ -23,6 +24,13 @@ public class TeamMemberCreatedDTO public string? Phone { get; init; } public required IReadOnlyList ServiceAreas { get; init; } public bool PendingRegistration { get; init; } + + /// + /// Set only when an invite was emailed: on create, and on an update that changed a pending member's email. + /// False means the invite email failed; re-invite from their details. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? InviteEmailSent { get; init; } } public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO diff --git a/SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs b/SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs new file mode 100644 index 0000000..36d7bd8 --- /dev/null +++ b/SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs @@ -0,0 +1,71 @@ +namespace SeaHaven.Services.DTOs; + +/// A freshly issued invite. The raw token is never persisted and never printed. +public sealed class IssuedTeamMemberInvite +{ + public IssuedTeamMemberInvite(string token, DateTime expiresAt) + { + Token = token; + ExpiresAt = expiresAt; + } + + public string Token { get; } + public DateTime ExpiresAt { get; } + + public override string ToString() => $"IssuedTeamMemberInvite {{ ExpiresAt = {ExpiresAt:O} }}"; +} + +public sealed class TeamMemberInviteResendOutcomeDTO +{ + public bool Success { get; init; } + public string? Error { get; init; } +} + +public enum TeamMemberRegistrationStatus +{ + Ok, + InvalidInvite, + CodeIncorrect, + CodeExpired, + CodeLocked, + ResendTooSoon, + ResendLimitReached, + CodeDeliveryFailed, + EmailNotConfirmed, + PasswordRejected, + InvalidPhone +} + +public sealed class TeamMemberInviteDetailsDTO +{ + public required string Name { get; init; } + public required string Role { get; init; } + public string? Email { get; init; } + public string? Phone { get; init; } +} + +public sealed class TeamMemberRegistrationOutcomeDTO +{ + public TeamMemberRegistrationStatus Status { get; init; } + public TeamMemberInviteDetailsDTO? Details { get; init; } + public LoginResultDTO? Session { get; init; } + public int? RetryAfterSeconds { get; init; } +} + +public sealed class TeamMemberInviteTokenRequestDTO +{ + public string? Token { get; init; } +} + +public sealed class VerifyTeamMemberInviteCodeRequestDTO +{ + public string? Token { get; init; } + public string? Code { get; init; } +} + +public sealed class CompleteTeamMemberRegistrationRequestDTO +{ + public string? Token { get; init; } + public string? Password { get; init; } + public string? Phone { get; init; } +} diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index e24dec3..455a0a8 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using SeaHaven.Services.Configuration; using SeaHaven.Services.Interfaces; using System.Reflection; @@ -13,6 +14,7 @@ namespace SeaHaven.Services.DependencyInjection public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) { + services.TryAddSingleton(TimeProvider.System); services.Configure(configuration); services.Configure(configuration.GetSection(JwtOptions.SectionName)); services.Configure(configuration.GetSection(ApprovalsOptions.SectionName)); diff --git a/SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs b/SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs new file mode 100644 index 0000000..0d65d61 --- /dev/null +++ b/SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Generation and hashing for invite tokens and email confirmation codes. Raw + /// values exist only in memory and in the email sent to the invitee. + /// + public static class TeamMemberInviteSecrets + { + /// 256 bits from the OS CSPRNG. + public const int TokenBytes = 32; + + /// Upper bound on an accepted token string; a 32-byte base64url token is 43 characters. + public const int MaxTokenLength = 128; + + public static string NewToken() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(TokenBytes)) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } + + public static string HashToken(string token) + { + ArgumentNullException.ThrowIfNull(token); + return Sha256Hex(token); + } + + public static string NewCode() + { + return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture); + } + + public static string NewCodeSalt() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + } + + public static string HashCode(string salt, string code) + { + ArgumentNullException.ThrowIfNull(salt); + ArgumentNullException.ThrowIfNull(code); + return Sha256Hex(salt + ":" + code); + } + + public static bool CodeMatches(string salt, string candidate, string expectedHash) + { + var actual = Encoding.ASCII.GetBytes(HashCode(salt, candidate)); + var expected = Encoding.ASCII.GetBytes(expectedHash); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + private static string Sha256Hex(string value) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } + } +} diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 2a44325..a9489a1 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -39,47 +39,53 @@ namespace SeaHaven.Services.Implementation { var user = await _userManager.FindByNameAsync(username ?? ""); if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? "")) - { - var userRoles = await _userManager.GetRolesAsync(user); - var authClaims = new List - { - new Claim(ClaimTypes.Name, user.UserName ?? ""), - new Claim(ClaimTypes.NameIdentifier, user.Id), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }; - foreach (var userRole in userRoles) - { - authClaims.Add(new Claim(ClaimTypes.Role, userRole)); - } - if (user.AccountId.HasValue) - { - authClaims.Add(new Claim( - SeaHavenClaimTypes.AccountId, - user.AccountId.Value.ToString())); - } - else if (userRoles.Contains("Admin")) - { - // Explicit signed org-wide elevation — never elevate via absence of account_id. - authClaims.Add(new Claim( - SeaHavenClaimTypes.OrgScope, - SeaHavenClaimTypes.OrgScopeAll)); - } - var token = GetToken(authClaims); - return new LoginResultDTO - { - Token = new JwtSecurityTokenHandler().WriteToken(token), - Expiration = token.ValidTo, - Email = user.Email, - UserRole = userRoles.FirstOrDefault(), - PhoneNumber = user.PhoneNumber, - Fullname = user.FirstName + " " + user.LastName, - Id = user.Id - }; - } + return await CreateSessionAsync(user, cancellationToken); return null; } + public async Task CreateSessionAsync(ApplicationUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + cancellationToken.ThrowIfCancellationRequested(); + + var userRoles = await _userManager.GetRolesAsync(user); + var authClaims = new List + { + new Claim(ClaimTypes.Name, user.UserName ?? ""), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + foreach (var userRole in userRoles) + { + authClaims.Add(new Claim(ClaimTypes.Role, userRole)); + } + if (user.AccountId.HasValue) + { + authClaims.Add(new Claim( + SeaHavenClaimTypes.AccountId, + user.AccountId.Value.ToString())); + } + else if (userRoles.Contains("Admin")) + { + // Explicit signed org-wide elevation — never elevate via absence of account_id. + authClaims.Add(new Claim( + SeaHavenClaimTypes.OrgScope, + SeaHavenClaimTypes.OrgScopeAll)); + } + var token = GetToken(authClaims); + return new LoginResultDTO + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + Expiration = token.ValidTo, + Email = user.Email, + UserRole = userRoles.FirstOrDefault(), + PhoneNumber = user.PhoneNumber, + Fullname = $"{user.FirstName} {user.LastName}".Trim(), + Id = user.Id + }; + } + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs new file mode 100644 index 0000000..e78130d --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation; + +public sealed class TeamMemberInviteService : ITeamMemberInviteService +{ + /// The prototype states no lifetime; seven days is the product decision. + public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7); + + private readonly ITeamMemberInviteDataService _inviteDataService; + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly FrontendOptions _frontendOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public TeamMemberInviteService( + ITeamMemberInviteDataService inviteDataService, + UserManager userManager, + IEmailSender emailSender, + IOptions frontendOptions, + TimeProvider timeProvider, + ILogger logger) + { + _inviteDataService = inviteDataService; + _userManager = userManager; + _emailSender = emailSender; + _frontendOptions = frontendOptions.Value; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task CreateAsync(string userId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + + var (invite, issued) = NewInvite(userId); + await _inviteDataService.AddAsync(invite, cancellationToken); + return issued; + } + + public async Task SendAsync( + IssuedTeamMemberInvite invite, + string email, + string name, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(invite); + cancellationToken.ThrowIfCancellationRequested(); + + var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}"; + var body = + $"

Hi {WebUtility.HtmlEncode(name)},

" + + "

You've been added to Seahaven. Set your password and confirm your email to finish creating your account.

" + + // clicktracking=off stops SendGrid rewriting the link, so the token never passes through its redirect. + $"

Finish registration

" + + $"

This link expires in {InviteLifetime.Days} days and can be used once.

"; + + var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body); + if (!sent) + _logger.LogWarning("Team member invite email could not be sent for invite expiring {ExpiresAt}.", invite.ExpiresAt); + + return sent; + } + + public async Task ResendAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken) + { + if (caller?.IsInRole("Admin") != true) + return ResendFailure("Forbidden"); + + cancellationToken.ThrowIfCancellationRequested(); + var user = await _userManager.FindByIdAsync(userId); + if (user is null) + return ResendFailure("Team member not found."); + + if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email)) + return ResendFailure("Only pending team members can be re-invited."); + + if (await ReissueAsync(user, cancellationToken) != true) + return ResendFailure("The invite could not be emailed. Try again."); + + return new TeamMemberInviteResendOutcomeDTO { Success = true }; + } + + public async Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentException.ThrowIfNullOrWhiteSpace(user.Email); + + var (invite, issued) = NewInvite(user.Id); + await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken); + + // A deactivated member cannot register; the admin re-sends the invite after reactivating them. + if (user.IsDeleted == true) + return null; + + var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(); + return await SendAsync(issued, user.Email, name, cancellationToken); + } + + private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId) + { + var now = NowUtc(); + var token = TeamMemberInviteSecrets.NewToken(); + var invite = new TeamMemberInvite + { + UserId = userId, + TokenHash = TeamMemberInviteSecrets.HashToken(token), + CreatedAt = now, + ExpiresAt = now.Add(InviteLifetime) + }; + return (invite, new IssuedTeamMemberInvite(token, invite.ExpiresAt)); + } + + private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; + + private static TeamMemberInviteResendOutcomeDTO ResendFailure(string error) => + new() { Success = false, Error = error }; +} diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs new file mode 100644 index 0000000..b83abd8 --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -0,0 +1,270 @@ +using System.Text.RegularExpressions; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation; + +public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrationService +{ + public static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15); + public static readonly TimeSpan ResendCooldown = TimeSpan.FromSeconds(60); + public const int MaxCodeAttempts = 5; + public const int MaxCodeSends = 10; + + private readonly ITeamMemberInviteDataService _inviteDataService; + private readonly IUserDataService _userDataService; + private readonly UserManager _userManager; + private readonly IAuthenticationService _authenticationService; + private readonly IEmailSender _emailSender; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public TeamMemberRegistrationService( + ITeamMemberInviteDataService inviteDataService, + IUserDataService userDataService, + UserManager userManager, + IAuthenticationService authenticationService, + IEmailSender emailSender, + TimeProvider timeProvider, + ILogger logger) + { + _inviteDataService = inviteDataService; + _userDataService = userDataService; + _userManager = userManager; + _authenticationService = authenticationService; + _emailSender = emailSender; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task ResolveAsync(string? token, CancellationToken cancellationToken) + { + var context = await LoadAsync(token, NowUtc(), cancellationToken); + if (context is null) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + + var roles = await _userManager.GetRolesAsync(context.User); + var role = roles.FirstOrDefault(); + return new TeamMemberRegistrationOutcomeDTO + { + Status = TeamMemberRegistrationStatus.Ok, + Details = new TeamMemberInviteDetailsDTO + { + Name = $"{context.User.FirstName ?? ""} {context.User.LastName ?? ""}".Trim(), + Role = TeamMemberConstants.CanonicalRole(role) ?? role ?? "", + Email = context.User.Email, + Phone = context.User.Contact ?? context.User.PhoneNumber + } + }; + } + + public async Task SendCodeAsync(string? token, CancellationToken cancellationToken) + { + var now = NowUtc(); + var context = await LoadAsync(token, now, cancellationToken); + if (context is null || string.IsNullOrWhiteSpace(context.User.Email)) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + + var code = TeamMemberInviteSecrets.NewCode(); + var salt = TeamMemberInviteSecrets.NewCodeSalt(); + var started = await _inviteDataService.TryStartCodeAsync( + new StartTeamMemberInviteCodeCommand + { + InviteId = context.Invite.Id, + CodeHash = TeamMemberInviteSecrets.HashCode(salt, code), + CodeSalt = salt, + Now = now, + CodeExpiresAt = now.Add(CodeLifetime), + LastSentNoLaterThan = now.Subtract(ResendCooldown), + MaxSends = MaxCodeSends + }, + cancellationToken); + if (!started) + { + // The refusal may be the invite closing since it was read, not the cooldown or limit: + // read it again so a revoked or used link gets the same answer as any other dead link. + var current = await LoadAsync(token, now, cancellationToken); + return current is null + ? Outcome(TeamMemberRegistrationStatus.InvalidInvite) + : ResendRefusal(current.Invite, now); + } + + // Read the address at send time so an admin's correction is honoured. + var body = + $"

Your Seahaven confirmation code is {code}.

" + + $"

It expires in {CodeLifetime.TotalMinutes:0} minutes. If you didn't request it, you can ignore this email.

"; + if (!await _emailSender.SendEmailAsync(context.User.Email, "Your Seahaven confirmation code", body)) + { + _logger.LogWarning("Confirmation code email could not be sent for team member invite {InviteId}.", context.Invite.Id); + return Outcome(TeamMemberRegistrationStatus.CodeDeliveryFailed); + } + + return Outcome(TeamMemberRegistrationStatus.Ok); + } + + public async Task VerifyCodeAsync( + string? token, + string? code, + CancellationToken cancellationToken) + { + var now = NowUtc(); + var context = await LoadAsync(token, now, cancellationToken); + if (context is null) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + + var invite = context.Invite; + if (invite.CodeHash is null || invite.CodeSalt is null || invite.CodeExpiresAt is null || invite.CodeExpiresAt <= now) + return Outcome(TeamMemberRegistrationStatus.CodeExpired); + + // Reserve the attempt before comparing, so parallel guesses cannot exceed the limit. + if (!await _inviteDataService.TryReserveCodeAttemptAsync(invite.Id, MaxCodeAttempts, now, cancellationToken)) + { + return Outcome(invite.CodeFailedAttempts >= MaxCodeAttempts + ? TeamMemberRegistrationStatus.CodeLocked + : TeamMemberRegistrationStatus.CodeExpired); + } + + if (!TeamMemberInviteSecrets.CodeMatches(invite.CodeSalt, (code ?? "").Trim(), invite.CodeHash)) + { + return Outcome(invite.CodeFailedAttempts + 1 >= MaxCodeAttempts + ? TeamMemberRegistrationStatus.CodeLocked + : TeamMemberRegistrationStatus.CodeIncorrect); + } + + return await _inviteDataService.TryConfirmEmailAsync(invite.Id, invite.CodeHash, now, cancellationToken) + ? Outcome(TeamMemberRegistrationStatus.Ok) + : Outcome(TeamMemberRegistrationStatus.CodeExpired); + } + + public async Task CompleteAsync( + CompleteTeamMemberRegistrationRequestDTO request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var now = NowUtc(); + var context = await LoadAsync(request.Token, now, cancellationToken); + if (context is null) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + if (context.Invite.EmailConfirmedAt is null) + return Outcome(TeamMemberRegistrationStatus.EmailNotConfirmed); + + // An omitted phone keeps what the admin entered; an explicit empty value clears it. + var updatePhone = request.Phone is not null; + var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim(); + if (phone is not null && !PhonePattern().IsMatch(phone)) + return Outcome(TeamMemberRegistrationStatus.InvalidPhone); + + var user = context.User; + try + { + await _userDataService.ExecuteTransactionalAsync( + async transactionCancellationToken => + { + if (!await _inviteDataService.TryClaimAsync(context.Invite.Id, now, transactionCancellationToken)) + throw new RegistrationAbortedException(TeamMemberRegistrationStatus.InvalidInvite); + + user.EmailConfirmed = true; + user.PendingRegistration = false; + user.UniqueName = "Active"; + if (updatePhone) + { + user.PhoneNumber = phone; + user.Contact = phone; + } + + // Identity applies the shared password policy and persists the user. + var result = await _userManager.AddPasswordAsync(user, request.Password ?? ""); + if (!result.Succeeded) + throw new RegistrationAbortedException(StatusFor(result)); + }, + cancellationToken); + } + catch (RegistrationAbortedException aborted) + { + return Outcome(aborted.Status); + } + + _logger.LogInformation("Team member {UserId} completed invite registration.", user.Id); + return new TeamMemberRegistrationOutcomeDTO + { + Status = TeamMemberRegistrationStatus.Ok, + Session = await _authenticationService.CreateSessionAsync(user, cancellationToken) + }; + } + + private async Task LoadAsync(string? token, DateTime now, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(token) || token.Length > TeamMemberInviteSecrets.MaxTokenLength) + return null; + + var invite = await _inviteDataService.GetByTokenHashAsync( + TeamMemberInviteSecrets.HashToken(token.Trim()), + cancellationToken); + if (invite is null || invite.UsedAt is not null || invite.RevokedAt is not null || invite.ExpiresAt <= now) + return null; + + var user = await _userManager.FindByIdAsync(invite.UserId); + if (user is null || user.IsDeleted == true || user.PendingRegistration != true) + return null; + + return new RegistrationContext(invite, user); + } + + private static TeamMemberRegistrationOutcomeDTO ResendRefusal(TeamMemberInviteData invite, DateTime now) + { + if (invite.CodeSendCount >= MaxCodeSends) + return Outcome(TeamMemberRegistrationStatus.ResendLimitReached); + + var allowedAt = (invite.CodeSentAt ?? now).Add(ResendCooldown); + return new TeamMemberRegistrationOutcomeDTO + { + Status = TeamMemberRegistrationStatus.ResendTooSoon, + RetryAfterSeconds = Math.Max(1, (int)Math.Ceiling((allowedAt - now).TotalSeconds)) + }; + } + + private static TeamMemberRegistrationStatus StatusFor(IdentityResult result) + { + // A password already set means the account was registered by another route. + if (result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword))) + return TeamMemberRegistrationStatus.InvalidInvite; + + if (IdentityPasswordPolicy.IsPolicyRejection(result)) + return TeamMemberRegistrationStatus.PasswordRejected; + + // Anything else (a concurrency conflict, a store failure) is not the member's password: + // roll back and let the endpoint answer with its generic error. Only codes are recorded. + throw new InvalidOperationException( + "Identity refused the registration: " + string.Join(", ", result.Errors.Select(error => error.Code))); + } + + private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; + + private static TeamMemberRegistrationOutcomeDTO Outcome(TeamMemberRegistrationStatus status) => + new() { Status = status }; + + [GeneratedRegex(@"^[0-9+()\-.\s]{7,32}$")] + private static partial Regex PhonePattern(); + + private sealed record RegistrationContext(TeamMemberInviteData Invite, ApplicationUser User); + + private sealed class RegistrationAbortedException : Exception + { + public RegistrationAbortedException(TeamMemberRegistrationStatus status) + : base(status.ToString()) + { + Status = status; + } + + public TeamMemberRegistrationStatus Status { get; } + } +} diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index faf64f3..6d78a7b 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -20,6 +20,7 @@ public sealed class TeamMemberService : ITeamMemberService private readonly ITeamPermissionOverrideDataService _permissionDataService; private readonly IUserDataService _userDataService; private readonly ITeamPermissionService _permissionService; + private readonly ITeamMemberInviteService _inviteService; public TeamMemberService( UserManager userManager, @@ -27,7 +28,8 @@ public sealed class TeamMemberService : ITeamMemberService IUserServiceAreaDataService areaDataService, ITeamPermissionOverrideDataService permissionDataService, IUserDataService userDataService, - ITeamPermissionService permissionService) + ITeamPermissionService permissionService, + ITeamMemberInviteService inviteService) { _userManager = userManager; _roleManager = roleManager; @@ -35,6 +37,7 @@ public sealed class TeamMemberService : ITeamMemberService _permissionDataService = permissionDataService; _userDataService = userDataService; _permissionService = permissionService; + _inviteService = inviteService; } public async Task CreateAsync( @@ -68,6 +71,7 @@ public sealed class TeamMemberService : ITeamMemberService PendingRegistrationCreatedDate = now }; + IssuedTeamMemberInvite? invite = null; try { await _userDataService.ExecuteTransactionalAsync( @@ -100,6 +104,7 @@ public sealed class TeamMemberService : ITeamMemberService await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken); await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken); + invite = await _inviteService.CreateAsync(user.Id, transactionCancellationToken); }, cancellationToken); } @@ -108,6 +113,10 @@ public sealed class TeamMemberService : ITeamMemberService return Failure(exception.Message); } + // The member and their invite commit together; the email goes out only after + // commit, so a rolled-back member never receives a link. + var inviteEmailSent = await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken); + return new CreateTeamMemberOutcomeDTO { Success = true, @@ -120,7 +129,8 @@ public sealed class TeamMemberService : ITeamMemberService Email = user.Email, Phone = user.PhoneNumber, ServiceAreas = areas!, - PendingRegistration = true + PendingRegistration = true, + InviteEmailSent = inviteEmailSent } }; } @@ -189,7 +199,8 @@ public sealed class TeamMemberService : ITeamMemberService user.UniqueName = request.IsActive ? ActiveStatus : "Inactive"; user.IsDeleted = !request.IsActive; - if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) + var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase); + if (emailChanged) { var emailResult = await _userManager.SetEmailAsync(user, email!); if (!emailResult.Succeeded) @@ -227,7 +238,12 @@ public sealed class TeamMemberService : ITeamMemberService await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken); } - return await OperationSuccessAsync(user, caller, cancellationToken); + // An invite sent to the old address, and any code confirmed through it, must not register the new one. + bool? inviteEmailSent = null; + if (emailChanged && user.PendingRegistration == true) + inviteEmailSent = await _inviteService.ReissueAsync(user, cancellationToken); + + return await OperationSuccessAsync(user, caller, cancellationToken, inviteEmailSent); } private static void ApplyName(ApplicationUser user, string fullName) @@ -262,7 +278,8 @@ public sealed class TeamMemberService : ITeamMemberService private async Task OperationSuccessAsync( ApplicationUser user, ClaimsPrincipal caller, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool? inviteEmailSent = null) { var roles = await _userManager.GetRolesAsync(user); var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken); @@ -280,6 +297,7 @@ public sealed class TeamMemberService : ITeamMemberService Phone = user.Contact ?? user.PhoneNumber, ServiceAreas = areas, PendingRegistration = user.PendingRegistration == true, + InviteEmailSent = inviteEmailSent, Status = user.UniqueName ?? "Active", IsAccountOwner = user.IsAccountOwner, Permissions = profile?.Value?.Permissions ?? Array.Empty() diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index e4ffc9b..5d41a32 100644 --- a/SeaHaven.Services/Interfaces/IAuthenticationService.cs +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -5,6 +5,8 @@ namespace SeaHaven.Services.Interfaces public interface IAuthenticationService { Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); + /// Builds the same signed session payload that a successful login returns. + 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); diff --git a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs new file mode 100644 index 0000000..25d5b6b --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs @@ -0,0 +1,31 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces; + +public interface ITeamMemberInviteService +{ + /// Persists a new invite for a pending member and returns its raw token. + Task CreateAsync(string userId, CancellationToken cancellationToken); + + /// Emails the invite link. Returns false when the email could not be handed off. + Task SendAsync( + IssuedTeamMemberInvite invite, + string email, + string name, + CancellationToken cancellationToken); + + /// + /// Revokes the member's open invites, including any email confirmation made through them, + /// and emails a new invite to their current address unless they are deactivated. + /// Returns whether that email went out, or null when the member is deactivated and nothing was emailed. + /// + Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken); + + /// Admin-only: revokes a pending member's open invites and emails a new one. + Task ResendAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken); +} diff --git a/SeaHaven.Services/Interfaces/ITeamMemberRegistrationService.cs b/SeaHaven.Services/Interfaces/ITeamMemberRegistrationService.cs new file mode 100644 index 0000000..8d2692c --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamMemberRegistrationService.cs @@ -0,0 +1,23 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces; + +/// +/// The anonymous invite-registration flow. Every operation is scoped by the invite +/// token alone; unknown, expired, used and revoked invites share one outcome. +/// +public interface ITeamMemberRegistrationService +{ + Task ResolveAsync(string? token, CancellationToken cancellationToken); + + Task SendCodeAsync(string? token, CancellationToken cancellationToken); + + Task VerifyCodeAsync( + string? token, + string? code, + CancellationToken cancellationToken); + + Task CompleteAsync( + CompleteTeamMemberRegistrationRequestDTO request, + CancellationToken cancellationToken); +} diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs index 8c5e2bc..0d690b5 100644 --- a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -35,7 +35,8 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); - await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true); + var emailSender = new CapturingEmailSender(); + await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true, emailSender); ThrowingAfterPersistPermissionDataService failingPermissionData; await using (var createScope = serviceProvider.CreateAsyncScope()) { @@ -66,6 +67,8 @@ public sealed class TeamMemberCreateTransactionTests Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.TeamMemberInvites.AsNoTracking().ToListAsync()); + Assert.Empty(emailSender.Messages); } [Fact] @@ -81,7 +84,8 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); - await using var serviceProvider = BuildServiceProvider(connection); + var emailSender = new CapturingEmailSender(); + await using var serviceProvider = BuildServiceProvider(connection, emailSender: emailSender); await using (var createScope = serviceProvider.CreateAsyncScope()) { var service = createScope.ServiceProvider.GetRequiredService(); @@ -110,9 +114,18 @@ public sealed class TeamMemberCreateTransactionTests var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking() .SingleAsync(permission => permission.UserId == user.Id); Assert.Equal("deleteSites", permissionOverride.PermissionKey); + + var invite = await verify.TeamMemberInvites.AsNoTracking().SingleAsync(); + Assert.Equal(user.Id, invite.UserId); + var sent = Assert.Single(emailSender.Messages); + Assert.Equal("taylor@example.com", sent.To); + Assert.Equal("You're invited to Seahaven", sent.Subject); } - private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false) + private static ServiceProvider BuildServiceProvider( + SqliteConnection connection, + bool injectFailure = false, + CapturingEmailSender? emailSender = null) { var configuration = new ConfigurationBuilder().Build(); var services = new ServiceCollection(); @@ -124,6 +137,7 @@ public sealed class TeamMemberCreateTransactionTests services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + services.AddSingleton(emailSender ?? new CapturingEmailSender()); services.AddDataServices(); services.AddBusinessServices(configuration); diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs new file mode 100644 index 0000000..9ddb383 --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs @@ -0,0 +1,139 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +/// What happens to a member's invites when an admin edits or deletes that member. +public sealed class TeamMemberInviteLifecycleTests +{ + private const string Email = "taylor@example.com"; + private const string CorrectedEmail = "taylor.reed@example.com"; + private const string Password = "Abc1!x"; + + [Fact] + public async Task DeletingAnInvitedMember_RemovesTheMemberAndTheirInvites() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.SendCodeAsync(token, Email); + Assert.True((await ResendInviteAsync(host, userId)).Success); + Assert.Equal(2, (await host.InvitesAsync(userId)).Count); + + var deleted = await host.InScopeAsync(provider => provider.GetRequiredService() + .DeleteUserAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); + + Assert.True(deleted.Success, deleted.Error); + Assert.Empty(await host.InvitesAsync(userId)); + Assert.False(await host.InScopeAsync(provider => provider.GetRequiredService() + .Users.AnyAsync(user => user.Id == userId))); + } + + [Fact] + public async Task ChangingAPendingMembersEmail_RevokesTheOldInviteAndItsConfirmation() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, oldToken) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(oldToken, Email); + + var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin"); + Assert.True(updated.Success, updated.Error); + Assert.True(updated.Member!.InviteEmailSent); + + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.SendCodeAsync(oldToken, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, (await CompleteAsync(host, oldToken)).Status); + + var user = await host.ReloadUserAsync(userId); + Assert.False(user.EmailConfirmed); + Assert.True(user.PendingRegistration); + + // The new address gets its own invite, and it must be confirmed again before finishing. + var newToken = host.Sent.LatestTokenFor(CorrectedEmail); + Assert.NotEqual(oldToken, newToken); + Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, newToken)).Status); + await host.ConfirmEmailAsync(newToken, CorrectedEmail); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, newToken)).Status); + } + + [Fact] + public async Task ChangingAPendingMembersEmail_ReportsWhenTheNewInviteCouldNotBeEmailed() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, _) = await host.AddPendingMemberAsync(Email); + host.Sent.Succeeds = false; + + var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "dispatcher"); + + Assert.True(updated.Success, updated.Error); + Assert.Equal(CorrectedEmail, updated.Member!.Email); + Assert.False(updated.Member.InviteEmailSent); + } + + [Fact] + public async Task DeactivatingAPendingMemberWhileChangingTheirEmail_RevokesTheInviteWithoutClaimingAnEmail() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, oldToken) = await host.AddPendingMemberAsync(Email); + + var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "dispatcher", isActive: false); + + Assert.True(updated.Success, updated.Error); + Assert.Null(updated.Member!.InviteEmailSent); + Assert.DoesNotContain(host.Sent.Messages, message => message.To == CorrectedEmail); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status); + } + + [Fact] + public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + var emailsBefore = host.Sent.Messages.Count; + + var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher"); + + Assert.True(updated.Success, updated.Error); + Assert.Null(updated.Member!.InviteEmailSent); + Assert.Equal(emailsBefore, host.Sent.Messages.Count); + var invite = Assert.Single(await host.InvitesAsync(userId)); + Assert.Null(invite.RevokedAt); + Assert.Equal(TeamMemberRegistrationStatus.Ok, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + } + + private static Task UpdateAsync( + TeamMemberInviteTestHost host, + string userId, + string email, + string role, + bool isActive = true) => + host.InScopeAsync(provider => provider.GetRequiredService().UpdateAsync( + userId, + new UpdateTeamMemberRequestDTO + { + Name = "Taylor Reed", + Role = role, + Color = "#0D9488", + Email = email, + Phone = "555-0100", + ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty(), + IsActive = isActive + }, + TeamMemberInviteTestHost.Admin(), + CancellationToken.None)); + + private static Task CompleteAsync(TeamMemberInviteTestHost host, string token) => + host.RegistrationAsync(service => service.CompleteAsync( + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }, + CancellationToken.None)); + + private static Task ResendInviteAsync(TeamMemberInviteTestHost host, string userId) => + host.InScopeAsync(provider => provider.GetRequiredService() + .ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); +} diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs new file mode 100644 index 0000000..1a986a1 --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -0,0 +1,675 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public sealed class TeamMemberInviteRegistrationTests +{ + private const string Email = "taylor@example.com"; + private const string Password = "Abc1!x"; + + [Fact] + public async Task CreatingPendingMember_EmailsHighEntropyInviteAndStoresOnlyItsHash() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + + var (userId, token) = await host.AddPendingMemberAsync(Email); + + var invite = Assert.Single(await host.InvitesAsync(userId)); + Assert.True(Base64UrlDecode(token).Length >= 16); + Assert.Equal(Sha256Hex(token), invite.TokenHash); + Assert.DoesNotContain(token, JsonSerializer.Serialize(invite)); + Assert.Equal(host.Time.Now.UtcDateTime.AddDays(7), invite.ExpiresAt); + Assert.Null(invite.UsedAt); + + var sent = Assert.Single(host.Sent.Messages); + Assert.Equal(Email, sent.To); + Assert.Contains($"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}", sent.Body); + Assert.Contains(" service.ResolveAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.Ok, resolved.Status); + Assert.Equal("Taylor Reed", resolved.Details!.Name); + Assert.Equal("Dispatcher", resolved.Details.Role); + Assert.Equal(Email, resolved.Details.Email); + + await host.ConfirmEmailAsync(token, Email); + var completed = await CompleteAsync(host, token, Password, "(555) 010-2000"); + + Assert.Equal(TeamMemberRegistrationStatus.Ok, completed.Status); + Assert.False(string.IsNullOrWhiteSpace(completed.Session!.Token)); + Assert.Equal(Email, completed.Session.Email); + Assert.Equal(userId, completed.Session.Id); + Assert.Equal("Taylor Reed", completed.Session.Fullname); + Assert.Contains("Dispatcher", completed.Session.UserRole); + + var user = await host.ReloadUserAsync(userId); + Assert.False(user.PendingRegistration); + Assert.True(user.EmailConfirmed); + Assert.Equal("Active", user.UniqueName); + Assert.Equal("(555) 010-2000", user.PhoneNumber); + Assert.Equal("(555) 010-2000", user.Contact); + Assert.True(await host.InScopeAsync(provider => + provider.GetRequiredService>().CheckPasswordAsync(user, Password))); + Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + } + + [Fact] + public async Task UsedToken_CannotRegisterAgain() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status); + + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await CompleteAsync(host, token, "Xyz9?q")).Status); + } + + [Fact] + public async Task UnknownExpiredUsedRevokedAndDeactivatedInvites_GetTheSameGenericResponse() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + + var (_, used) = await host.AddPendingMemberAsync("used@example.com"); + await host.ConfirmEmailAsync(used, "used@example.com"); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, used, Password)).Status); + + var (revokedUserId, revoked) = await host.AddPendingMemberAsync("revoked@example.com"); + Assert.True((await ResendInviteAsync(host, revokedUserId)).Success); + + var (deactivatedUserId, deactivated) = await host.AddPendingMemberAsync("deactivated@example.com"); + await host.InScopeAsync(provider => provider.GetRequiredService().Users + .Where(user => user.Id == deactivatedUserId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(user => user.IsDeleted, true) + .SetProperty(user => user.UniqueName, "Inactive"))); + + var (_, expired) = await host.AddPendingMemberAsync("expired@example.com"); + + var responses = new List + { + await ResolveResponseAsync(host, used), + await ResolveResponseAsync(host, revoked), + await ResolveResponseAsync(host, deactivated), + await ResolveResponseAsync(host, TeamMemberInviteSecretsForTests.UnknownToken()), + await ResolveResponseAsync(host, ""), + await ResolveResponseAsync(host, new string('a', 4096)) + }; + host.Time.Advance(TimeSpan.FromDays(7).Add(TimeSpan.FromSeconds(1))); + responses.Add(await ResolveResponseAsync(host, expired)); + + var expected = + "400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}"; + Assert.All(responses, response => Assert.Equal(expected, response)); + } + + [Fact] + public async Task Invite_ExpiresAfterSevenDays() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + + host.Time.Advance(TimeSpan.FromDays(7).Subtract(TimeSpan.FromSeconds(1))); + Assert.Equal(TeamMemberRegistrationStatus.Ok, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + + host.Time.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + } + + [Fact] + public async Task Code_IsSixDigitsAndStoredOnlyAsSaltedHash() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + + var code = await host.SendCodeAsync(token, Email); + + Assert.Matches("^[0-9]{6}$", code); + var invite = Assert.Single(await host.InvitesAsync(userId)); + Assert.NotNull(invite.CodeHash); + Assert.NotNull(invite.CodeSalt); + Assert.NotEqual(code, invite.CodeHash); + Assert.DoesNotContain(code, invite.CodeHash); + Assert.Equal(Sha256Hex($"{invite.CodeSalt}:{code}"), invite.CodeHash); + Assert.Equal(host.Time.Now.UtcDateTime.AddMinutes(15), invite.CodeExpiresAt); + } + + [Fact] + public async Task WrongCodes_AreCountedAndLockAfterFive_UntilANewCodeIsSent() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + var wrong = code == "000000" ? "111111" : "000000"; + + for (var attempt = 1; attempt <= 4; attempt++) + { + var outcome = await VerifyAsync(host, token, wrong); + Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, outcome.Status); + Assert.Equal(attempt, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts); + } + + Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, wrong)).Status); + Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, code)).Status); + Assert.Equal(5, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt); + + host.Time.Advance(TeamMemberRegistrationService.ResendCooldown); + var fresh = await host.SendCodeAsync(token, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, fresh)).Status); + Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt); + } + + [Fact] + public async Task AttemptReservation_IsEnforcedByTheDatabaseGuard() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.SendCodeAsync(token, Email); + var inviteId = Assert.Single(await host.InvitesAsync(userId)).Id; + + var granted = 0; + for (var i = 0; i < 8; i++) + { + if (await host.InScopeAsync(provider => provider.GetRequiredService() + .TryReserveCodeAttemptAsync(inviteId, TeamMemberRegistrationService.MaxCodeAttempts, host.Time.Now.UtcDateTime, CancellationToken.None))) + granted++; + } + + Assert.Equal(TeamMemberRegistrationService.MaxCodeAttempts, granted); + } + + [Fact] + public async Task Code_ExpiresAfterFifteenMinutes() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + + host.Time.Advance(TeamMemberRegistrationService.CodeLifetime); + + Assert.Equal(TeamMemberRegistrationStatus.CodeExpired, (await VerifyAsync(host, token, code)).Status); + } + + [Fact] + public async Task Resend_IsRateLimitedAndReplacesThePreviousCode() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + var first = await host.SendCodeAsync(token, Email); + + var tooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, tooSoon.Status); + Assert.Equal(60, tooSoon.RetryAfterSeconds); + + host.Time.Advance(TimeSpan.FromSeconds(45)); + var stillTooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, stillTooSoon.Status); + Assert.Equal(15, stillTooSoon.RetryAfterSeconds); + Assert.Single(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code"); + + host.Time.Advance(TimeSpan.FromSeconds(15)); + var second = await host.SendCodeAsync(token, Email); + if (second != first) + Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, (await VerifyAsync(host, token, first)).Status); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, second)).Status); + } + + [Fact] + public async Task Resend_StopsAfterTheSendLimit() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + + for (var send = 0; send < TeamMemberRegistrationService.MaxCodeSends; send++) + { + await host.SendCodeAsync(token, Email); + host.Time.Advance(TeamMemberRegistrationService.ResendCooldown); + } + + var refused = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.ResendLimitReached, refused.Status); + Assert.Equal(TeamMemberRegistrationService.MaxCodeSends, + host.Sent.Messages.Count(message => message.Subject == "Your Seahaven confirmation code")); + } + + [Theory] + [InlineData("revoked")] + [InlineData("used")] + public async Task SendCode_ForAnInviteClosedMidRequest_GetsTheGenericInvalidInviteResponse(string closedBy) + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(services => + services.Replace(ServiceDescriptor.Scoped(provider => + new ClosesInviteBeforeCodeStart( + new TeamMemberInviteDataService(provider.GetRequiredService()), + provider.GetRequiredService(), + closedBy)))); + var (userId, token) = await host.AddPendingMemberAsync(Email); + + var response = await InvokeControllerAsync(host, controller => controller.SendCode( + new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None)); + + Assert.Equal( + "400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}", + response.Body); + Assert.Null(response.RetryAfter); + Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code"); + Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount); + } + + /// Closes the invite after the service has read it and before the conditional code start runs. + private sealed class ClosesInviteBeforeCodeStart : ITeamMemberInviteDataService + { + private readonly ITeamMemberInviteDataService _inner; + private readonly ApplicationDbContext _context; + private readonly string _closedBy; + + public ClosesInviteBeforeCodeStart(ITeamMemberInviteDataService inner, ApplicationDbContext context, string closedBy) + { + _inner = inner; + _context = context; + _closedBy = closedBy; + } + + public async Task TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken) + { + var invite = _context.TeamMemberInvites.Where(existing => existing.Id == command.InviteId); + if (_closedBy == "revoked") + await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.RevokedAt, command.Now), cancellationToken); + else + await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.UsedAt, command.Now), cancellationToken); + return await _inner.TryStartCodeAsync(command, cancellationToken); + } + + public Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken) => + _inner.AddAsync(invite, cancellationToken); + + public Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken) => + _inner.ReplaceOpenForUserAsync(invite, now, cancellationToken); + + public Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) => + _inner.GetByTokenHashAsync(tokenHash, cancellationToken); + + public Task TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken) => + _inner.TryReserveCodeAttemptAsync(inviteId, maxAttempts, now, cancellationToken); + + public Task TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken) => + _inner.TryConfirmEmailAsync(inviteId, codeHash, now, cancellationToken); + + public Task TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) => + _inner.TryClaimAsync(inviteId, now, cancellationToken); + } + + [Fact] + public async Task Complete_RequiresConfirmedEmail() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.SendCodeAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, token, Password)).Status); + + var user = await host.ReloadUserAsync(userId); + Assert.True(user.PendingRegistration); + Assert.Null(user.PasswordHash); + } + + [Fact] + public async Task Complete_WeakPassword_RollsBackAndARetrySucceeds() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.PasswordRejected, (await CompleteAsync(host, token, "abc12!")).Status); + + var pending = await host.ReloadUserAsync(userId); + Assert.True(pending.PendingRegistration); + Assert.False(pending.EmailConfirmed); + Assert.Null(pending.PasswordHash); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status); + Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration); + } + + [Fact] + public async Task Complete_NonPolicyIdentityFailure_IsNotReportedAsAWeakPasswordAndRollsBack() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(services => + services.AddScoped, FailingPasswordValidator>()); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + var failure = await Assert.ThrowsAsync(() => CompleteAsync(host, token, Password)); + + Assert.Contains(FailingPasswordValidator.Code, failure.Message); + Assert.DoesNotContain(Password, failure.Message); + var pending = await host.ReloadUserAsync(userId); + Assert.True(pending.PendingRegistration); + Assert.Null(pending.PasswordHash); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + } + + /// Stands in for any Identity failure that is not the password rule itself. + private sealed class FailingPasswordValidator : IPasswordValidator + { + public const string Code = "ConcurrencyFailure"; + + public Task ValidateAsync(UserManager manager, ApplicationUser user, string? password) => + Task.FromResult(IdentityResult.Failed(new IdentityError { Code = Code, Description = "Optimistic concurrency failure." })); + } + + [Fact] + public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password, phone: null)).Status); + + var user = await host.ReloadUserAsync(userId); + Assert.Equal("555-0100", user.PhoneNumber); + Assert.Equal("555-0100", user.Contact); + } + + [Fact] + public async Task Complete_WithAnEmptyPhone_ClearsIt() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password, phone: "")).Status); + + var user = await host.ReloadUserAsync(userId); + Assert.Null(user.PhoneNumber); + Assert.Null(user.Contact); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreatingPendingMember_ReportsWhetherTheInviteEmailWentOut(bool delivered) + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + host.Sent.Succeeds = delivered; + + var outcome = await host.InScopeAsync(provider => provider.GetRequiredService().CreateAsync( + new CreateTeamMemberRequestDTO + { + Name = "Taylor Reed", + Role = "dispatcher", + Color = "#0D9488", + Email = Email, + ServiceAreas = new[] { "East" } + }, + TeamMemberInviteTestHost.Admin(), + CancellationToken.None)); + + Assert.True(outcome.Success); + Assert.Equal(delivered, outcome.Member!.InviteEmailSent); + Assert.Single(await host.InvitesAsync(outcome.Member.Id)); + } + + [Fact] + public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.InvalidPhone, (await CompleteAsync(host, token, Password, "call me")).Status); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + } + + [Fact] + public async Task Invite_OnlyEverChangesItsOwnMember() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (ownerId, ownerToken) = await host.AddPendingMemberAsync(Email, "Taylor Reed"); + var (otherId, otherToken) = await host.AddPendingMemberAsync("jordan@example.com", "Jordan Lee"); + var otherBefore = await host.ReloadUserAsync(otherId); + + await host.ConfirmEmailAsync(ownerToken, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, ownerToken, Password, "555-123-4567")).Status); + + Assert.DoesNotContain(host.Sent.Messages, + message => message.To == "jordan@example.com" && message.Subject == "Your Seahaven confirmation code"); + var otherAfter = await host.ReloadUserAsync(otherId); + Assert.True(otherAfter.PendingRegistration); + Assert.False(otherAfter.EmailConfirmed); + Assert.Null(otherAfter.PasswordHash); + Assert.Equal(otherBefore.PhoneNumber, otherAfter.PhoneNumber); + Assert.Equal(otherBefore.SecurityStamp, otherAfter.SecurityStamp); + var otherInvite = Assert.Single(await host.InvitesAsync(otherId)); + Assert.Null(otherInvite.UsedAt); + Assert.Null(otherInvite.EmailConfirmedAt); + + var otherDetails = await host.RegistrationAsync(service => service.ResolveAsync(otherToken, CancellationToken.None)); + Assert.Equal("Jordan Lee", otherDetails.Details!.Name); + Assert.False((await host.ReloadUserAsync(ownerId)).PendingRegistration); + } + + [Fact] + public async Task Reinvite_RevokesOlderTokensAndOnlyAdminsCanSendIt() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, original) = await host.AddPendingMemberAsync(Email); + + var forbidden = await host.InScopeAsync(provider => provider.GetRequiredService() + .ResendAsync(userId, TeamMemberInviteTestHost.Dispatcher(), CancellationToken.None)); + Assert.Equal("Forbidden", forbidden.Error); + + Assert.True((await ResendInviteAsync(host, userId)).Success); + var replacement = host.Sent.LatestTokenFor(Email); + + Assert.NotEqual(original, replacement); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(original, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.Ok, + (await host.RegistrationAsync(service => service.ResolveAsync(replacement, CancellationToken.None))).Status); + var invites = await host.InvitesAsync(userId); + Assert.Equal(2, invites.Count); + Assert.NotNull(invites[0].RevokedAt); + Assert.Null(invites[1].RevokedAt); + } + + [Fact] + public async Task Reinvite_IsRefusedOnceTheMemberIsActive() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status); + + var refused = await ResendInviteAsync(host, userId); + + Assert.False(refused.Success); + Assert.Equal("Only pending team members can be re-invited.", refused.Error); + } + + [Fact] + public async Task TokensAndCodes_NeverReachTheLogs() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + await VerifyAsync(host, token, code == "000000" ? "111111" : "000000"); + + host.Sent.Succeeds = false; + host.Time.Advance(TeamMemberRegistrationService.ResendCooldown); + Assert.Equal(TeamMemberRegistrationStatus.CodeDeliveryFailed, + (await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status); + Assert.False((await ResendInviteAsync(host, userId)).Success); + var failedInviteToken = host.Sent.LatestTokenFor(Email); + host.Sent.Succeeds = true; + + Assert.True((await ResendInviteAsync(host, userId)).Success); + var liveToken = host.Sent.LatestTokenFor(Email); + await host.ConfirmEmailAsync(liveToken, Email); + var liveCode = host.Sent.LatestCodeFor(Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, liveToken, Password)).Status); + + Assert.NotEmpty(host.Logged.Entries); + Assert.Contains(host.Logged.Entries, entry => entry.Contains("could not be sent", StringComparison.Ordinal)); + foreach (var secret in new[] { token, failedInviteToken, liveToken, code, liveCode, Password }) + Assert.DoesNotContain(host.Logged.Entries, entry => entry.Contains(secret, StringComparison.Ordinal)); + } + + [Fact] + public async Task RequestObjects_DoNotPrintTheirSecrets() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + + var printed = string.Join(" ", + new TeamMemberInviteTokenRequestDTO { Token = token }.ToString(), + new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = "123456" }.ToString(), + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }.ToString(), + new IssuedTeamMemberInvite(token, DateTime.UtcNow).ToString()); + + Assert.DoesNotContain(token, printed); + Assert.DoesNotContain("123456", printed); + Assert.DoesNotContain(Password, printed); + } + + [Fact] + public async Task CancelledRequests_StopBeforeChangingAnything() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + host.RegistrationAsync(service => service.SendCodeAsync(token, cancelled.Token))); + await Assert.ThrowsAnyAsync(() => host.RegistrationAsync(service => + service.CompleteAsync(new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }, cancelled.Token))); + + Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code"); + Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount); + Assert.True((await host.ReloadUserAsync(userId)).PendingRegistration); + } + + [Fact] + public async Task Controller_MapsCodeFailuresToClearMessagesAndRetryAfter() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + + var wrong = await InvokeControllerAsync(host, controller => controller.VerifyCode( + new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = code == "000000" ? "111111" : "000000" }, + CancellationToken.None)); + Assert.Equal( + "400 {\"code\":\"code_incorrect\",\"message\":\"Incorrect code \\u2014 check your email and try again\",\"retryAfterSeconds\":null}", + wrong.Body); + + var tooSoon = await InvokeControllerAsync(host, controller => controller.SendCode( + new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None)); + Assert.StartsWith("429 {\"code\":\"resend_too_soon\"", tooSoon.Body); + Assert.Equal("60", tooSoon.RetryAfter); + } + + [Fact] + public async Task Controller_CompleteReturnsTheLoginPayloadShape() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + var response = await InvokeControllerAsync(host, controller => controller.Complete( + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password, Phone = "555-123-4567" }, + CancellationToken.None)); + + Assert.StartsWith("200 ", response.Body); + using var payload = JsonDocument.Parse(response.Body[4..]); + var keys = payload.RootElement.EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal(new[] { "token", "expiration", "email", "userRoles", "phoneNumber", "fullname", "id" }, keys); + Assert.Equal(userId, payload.RootElement.GetProperty("id").GetString()); + Assert.Equal("555-123-4567", payload.RootElement.GetProperty("phoneNumber").GetString()); + } + + private static Task VerifyAsync(TeamMemberInviteTestHost host, string token, string code) => + host.RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None)); + + private static Task CompleteAsync( + TeamMemberInviteTestHost host, + string token, + string password, + string? phone = null) => + host.RegistrationAsync(service => service.CompleteAsync( + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = password, Phone = phone }, + CancellationToken.None)); + + private static Task ResendInviteAsync(TeamMemberInviteTestHost host, string userId) => + host.InScopeAsync(provider => provider.GetRequiredService() + .ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); + + private static async Task ResolveResponseAsync(TeamMemberInviteTestHost host, string token) => + (await InvokeControllerAsync(host, controller => controller.Resolve( + new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None))).Body; + + private static Task<(string Body, string? RetryAfter)> InvokeControllerAsync( + TeamMemberInviteTestHost host, + Func> action) => + host.InScopeAsync(async provider => + { + var httpContext = new DefaultHttpContext(); + var controller = new TeamMemberInviteController(provider.GetRequiredService()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + var result = Assert.IsAssignableFrom(await action(controller)); + var status = result.StatusCode ?? StatusCodes.Status200OK; + var retryAfter = httpContext.Response.Headers.RetryAfter.ToString(); + return ($"{status} {JsonSerializer.Serialize(result.Value)}", string.IsNullOrEmpty(retryAfter) ? null : retryAfter); + }); + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + padded += new string('=', (4 - padded.Length % 4) % 4); + return Convert.FromBase64String(padded); + } + + private static string Sha256Hex(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private static class TeamMemberInviteSecretsForTests + { + public static string UnknownToken() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } +} diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs new file mode 100644 index 0000000..a2e5a1a --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs @@ -0,0 +1,262 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Text.RegularExpressions; +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +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.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +/// +/// A SQLite-backed composition of the real team member, invite and registration +/// services, with Identity registered exactly as the API host registers it. Email +/// goes to an in-memory sender and every log line is captured. +/// +internal sealed class TeamMemberInviteTestHost : IAsyncDisposable +{ + public const string FrontendBaseUrl = "https://shoc.test"; + + private readonly SqliteConnection _connection; + private ServiceProvider _provider = null!; + + private TeamMemberInviteTestHost(SqliteConnection connection) + { + _connection = connection; + } + + public CapturingEmailSender Sent { get; } = new(); + public ManualTimeProvider Time { get; } = new(); + public CapturingLoggerProvider Logged { get; } = new(); + + public static async Task CreateAsync(Action? configure = null) + { + var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using (var setup = new SqliteInviteTestDbContext(options)) + await setup.Database.EnsureCreatedAsync(); + + var host = new TeamMemberInviteTestHost(connection); + host._provider = BuildProvider(connection, host, configure); + return host; + } + + private static ServiceProvider BuildProvider( + SqliteConnection connection, + TeamMemberInviteTestHost fakes, + Action? configure) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FrontendBaseUrl"] = FrontendBaseUrl, + ["JWT:Secret"] = new string('k', 64), + ["JWT:ValidIssuer"] = "issuer", + ["JWT:ValidAudience"] = "audience" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(logging => logging + .SetMinimumLevel(LogLevel.Trace) + .AddProvider(fakes.Logged)); + services.AddDbContext(builder => builder.UseSqlite(connection)); + services.Replace(ServiceDescriptor.Scoped(provider => + new SqliteInviteTestDbContext(provider.GetRequiredService>()))); + services.AddSeaHavenIdentity(); + services.AddSingleton(fakes.Time); + services.AddSingleton(fakes.Sent); + services.AddDataServices(); + services.AddBusinessServices(configuration); + configure?.Invoke(services); + return services.BuildServiceProvider(); + } + + public async Task InScopeAsync(Func> action) + { + await using var scope = _provider.CreateAsyncScope(); + return await action(scope.ServiceProvider); + } + + public Task RegistrationAsync( + Func> action) => + InScopeAsync(provider => action(provider.GetRequiredService())); + + /// Creates a pending member through the admin create path and returns the emailed token. + public async Task<(string UserId, string Token)> AddPendingMemberAsync(string email, string name = "Taylor Reed") + { + var outcome = await InScopeAsync(provider => provider.GetRequiredService().CreateAsync( + new CreateTeamMemberRequestDTO + { + Name = name, + Role = "dispatcher", + Color = "#0D9488", + Email = email, + Phone = "555-0100", + ServiceAreas = new[] { "East" } + }, + Admin(), + CancellationToken.None)); + Assert.True(outcome.Success, outcome.Error); + + return (outcome.Member!.Id, Sent.LatestTokenFor(email)); + } + + public async Task SendCodeAsync(string token, string email) + { + var outcome = await RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.Ok, outcome.Status); + return Sent.LatestCodeFor(email); + } + + public async Task ConfirmEmailAsync(string token, string email) + { + var code = await SendCodeAsync(token, email); + var verified = await RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.Ok, verified.Status); + } + + public Task ReloadUserAsync(string userId) => + InScopeAsync(async provider => await provider.GetRequiredService() + .Users.AsNoTracking().SingleAsync(user => user.Id == userId)); + + public Task> InvitesAsync(string userId) => + InScopeAsync(provider => provider.GetRequiredService() + .TeamMemberInvites.AsNoTracking().Where(invite => invite.UserId == userId) + .OrderBy(invite => invite.Id).ToListAsync()); + + public static ClaimsPrincipal Admin() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test")); + + public static ClaimsPrincipal Dispatcher() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Dispatcher") }, "Test")); + + public async ValueTask DisposeAsync() + { + await _provider.DisposeAsync(); + await _connection.DisposeAsync(); + } + + private sealed class SqliteInviteTestDbContext : ApplicationDbContext + { + public SqliteInviteTestDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // Keep filtered unique indexes filtered: translate SQL Server identifier quoting. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + if (index.GetFilter() is { } filter) + index.SetFilter(filter.Replace('[', '"').Replace(']', '"')); + } + + 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 partial class CapturingEmailSender : IEmailSender +{ + private readonly ConcurrentQueue _messages = new(); + + public bool Succeeds { get; set; } = true; + + public IReadOnlyList Messages => _messages.ToArray(); + + public Task SendEmailAsync(string emailTo, string subject, string body) + { + _messages.Enqueue(new SentEmail(emailTo, subject, body)); + return Task.FromResult(Succeeds); + } + + public string LatestTokenFor(string email) + { + var message = Messages.Last(sent => sent.To == email && sent.Subject == "You're invited to Seahaven"); + return InviteLink().Match(message.Body).Groups["token"].Value; + } + + public string LatestCodeFor(string email) + { + var message = Messages.Last(sent => sent.To == email && sent.Subject == "Your Seahaven confirmation code"); + return Code().Match(message.Body).Groups["code"].Value; + } + + [GeneratedRegex("https://shoc\\.test/invite#(?[A-Za-z0-9_-]+)")] + public static partial Regex InviteLink(); + + [GeneratedRegex("(?[0-9]{6})")] + private static partial Regex Code(); +} + +internal sealed record SentEmail(string To, string Subject, string Body); + +internal sealed class ManualTimeProvider : TimeProvider +{ + public DateTimeOffset Now { get; private set; } = 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 CapturingLoggerProvider : ILoggerProvider +{ + private readonly ConcurrentQueue _entries = new(); + + public IReadOnlyList Entries => _entries.ToArray(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(_entries); + + public void Dispose() + { + } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _entries; + + public CapturingLogger(ConcurrentQueue entries) => _entries = entries; + + public IDisposable? BeginScope(TState state) where TState : notnull + { + _entries.Enqueue(state.ToString() ?? ""); + return 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}")) + : ""; + _entries.Enqueue($"{formatter(state, exception)} {values} {exception}"); + } + } +}