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