Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2

This commit is contained in:
Alexandre Brandizzi 2026-09-25 17:54:13 -03:00
commit 35e79a276d
54 changed files with 7550 additions and 142 deletions

View file

@ -213,10 +213,10 @@ public class DashboardServiceTests
AccountUser(1), query, CancellationToken.None);
stats.Total.Should().Be(4);
stats.Breakdown.Overdue.Should().Be(1);
stats.Breakdown.Overdue.Should().Be(0);
stats.Breakdown.Other.Should().Be(0);
stats.Breakdown.PM.Should().Be(2);
stats.Breakdown.Emergency.Should().Be(0);
stats.Breakdown.Emergency.Should().Be(1);
stats.Breakdown.Reactive.Should().Be(1);
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);

View file

@ -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, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 }));
Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "<p>Hi</p>", 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, "<p>secret link</p>"));
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, "<p>Hi</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains(nameof(HttpRequestException), entry);
Assert.DoesNotContain(Recipient, entry);
}
private sealed class StubbedSendMessage : SendMessage
{
private readonly Mock<ISendGridClient> _client = new();
public StubbedSendMessage(HttpStatusCode status, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null));
}
public StubbedSendMessage(Exception failure, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(failure);
}
protected override ISendGridClient CreateClient(string? apiKey) => _client.Object;
}
private sealed class CapturingLogger : ILogger<SendMessage>
{
public List<string> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add($"{formatter(state, exception)} {exception}");
}
}

View file

@ -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<AllowAnonymousAttribute>().Should().NotBeNull();
var cache = type.GetCustomAttribute<ResponseCacheAttribute>();
cache.Should().NotBeNull();
cache!.NoStore.Should().BeTrue();
}
[Fact]
public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter()
{
var filter = typeof(TeamMemberInviteController).GetCustomAttribute<TypeFilterAttribute>();
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<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeTrue();
var result = context.Result.Should().BeOfType<ObjectResult>().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<InviteRegistrationExceptionFilter>.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<IFilterMetadata>()) { Exception = exception };
}
[Theory]
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
[InlineData(TeamMemberRegistrationStatus.Ok)]
public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status)
{
var service = new Mock<ITeamMemberRegistrationService>();
service.Setup(x => x.CompleteAsync(It.IsAny<CompleteTeamMemberRegistrationRequestDTO>(), It.IsAny<CancellationToken>()))
.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<ObjectResult>().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<AuthorizeAttribute>().Should().NotBeNull();
typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))!
.GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
}
[Fact]
public async Task ResendInvite_Success_ReturnsInviteSent()
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType<OkObjectResult>().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<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ITeamMemberInviteService> invites)
{
return new TeamMemberController(Mock.Of<ITeamMemberService>(), Mock.Of<ITeamPermissionService>(), invites.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"))
}
}
};
}
}

View file

@ -85,7 +85,10 @@ public sealed class TeamMemberPermissionsEndpointTests
private static TeamMemberController Controller(
ITeamPermissionOverrideDataService data,
ClaimsPrincipal user) =>
new(Mock.Of<ITeamMemberService>(), new TeamPermissionService(data, new TeamPermissionPolicy()))
new(
Mock.Of<ITeamMemberService>(),
new TeamPermissionService(data, new TeamPermissionPolicy()),
Mock.Of<ITeamMemberInviteService>())
{
ControllerContext = new ControllerContext
{

View file

@ -28,7 +28,8 @@ public sealed class TeamMemberServiceTests
Mock.Of<IUserServiceAreaDataService>(),
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>());
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
@ -408,7 +409,8 @@ public sealed class TeamMemberServiceTests
areas.Object,
overrides.Object,
userData.Object,
permissions.Object);
permissions.Object,
Mock.Of<ITeamMemberInviteService>());
}
private static Mock<UserManager<ApplicationUser>> UserManager()

View file

@ -37,16 +37,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();

View file

@ -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<IActionResult> 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<IActionResult> Create(
CreateTeamMemberRequestDTO request,

View file

@ -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;
/// <summary>
/// Anonymous invite registration. The invite token travels only in request bodies,
/// and every failure maps to a fixed public message.
/// </summary>
[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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}

View file

@ -0,0 +1,24 @@
using SeaHaven.Services.DTOs;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>The session payload the web app stores after sign-in.</summary>
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
};
}
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class InviteRegistrationExceptionFilter : IExceptionFilter
{
public const string Message = "Something went wrong. Try again in a minute.";
private readonly ILogger<InviteRegistrationExceptionFilter> _logger;
public InviteRegistrationExceptionFilter(ILogger<InviteRegistrationExceptionFilter> 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;
}
}
}

View file

@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
private readonly ILogger<SendMessage> _logger;
//string keysapi = "";
public SendMessage(IConfiguration configuration)
public SendMessage(IConfiguration configuration, ILogger<SendMessage> logger)
{
_configuration = configuration;
_logger = logger;
//keysapi = _configuration.GetValue<string>("SendGrid:ApiKey");
}
protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey);
/// <summary>
/// SendGrid reports a rejected message through the status code, not an exception.
/// Only the status is logged: never the recipient, subject or response body.
/// </summary>
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<bool> SendEMail(string emailTo, string subject, string body)
{
try
@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
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<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
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<SendGrid.Helpers.Mail.Attachment> { 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<bool> SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper
try
{
var apiKey = _configuration.GetValue<string>("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);
}
}
}

View file

@ -328,6 +328,21 @@ namespace Data.SeaHavenIndustries
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
});
builder.Entity<TeamMemberInvite>(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<UserServiceArea>(entity =>
{
entity.HasKey(area => new { area.UserId, area.Area });
@ -406,6 +421,7 @@ namespace Data.SeaHavenIndustries
public DbSet<Region> Regions { get; set; }
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
public DbSet<TeamMemberInvite> TeamMemberInvites { get; set; }
public override int SaveChanges()
{

View file

@ -35,6 +35,8 @@ namespace Data.SeaHavenIndustries.Enums
Inspection = 5,
Reactive = 6,
AddOn = 7,
/// <summary>Client never responded after the due date passed. Distinct from the derived past-due overlay.</summary>
Overdue = 8,
Other = 99
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddTeamMemberInvites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeamMemberInvites",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
TokenHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UsedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RevokedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
CodeSalt = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
CodeExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeFailedAttempts = table.Column<int>(type: "int", nullable: false),
CodeSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeSendCount = table.Column<int>(type: "int", nullable: false),
EmailConfirmedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeamMemberInvites");
}
}
}

View file

@ -2120,6 +2120,71 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("TaskListTemplateItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CodeExpiresAt")
.HasColumnType("datetime2");
b.Property<int>("CodeFailedAttempts")
.HasColumnType("int");
b.Property<string>("CodeHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CodeSalt")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("CodeSendCount")
.HasColumnType("int");
b.Property<DateTime?>("CodeSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime?>("UsedAt")
.HasColumnType("datetime2");
b.Property<string>("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<int>("Id")
@ -3849,6 +3914,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")

View file

@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// A single-use registration invite for one pending team member. Only hashes of
/// the invite token and of the email confirmation code are stored.
/// </summary>
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; }
/// <summary>Lowercase hex SHA-256 of the raw invite token.</summary>
[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; }
/// <summary>Lowercase hex SHA-256 of the salt followed by the confirmation code.</summary>
[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; }
}
}

View file

@ -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; }
}
/// <summary>
/// Replaces the confirmation code on an open invite when the resend cooldown has
/// elapsed and the send limit has not been reached.
/// </summary>
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; }
/// <summary>A code may be replaced only when the previous one was sent at or before this instant.</summary>
public required DateTime LastSentNoLaterThan { get; init; }
public required int MaxSends { get; init; }
}
}

View file

@ -58,9 +58,9 @@ namespace SeaHaven.DataServices.Helpers
}
/// <summary>
/// Filters by real <paramref name="types"/> (including <see cref="WorkOrderType.Other"/>)
/// and/or past-due rows when <paramref name="overdue"/> is true.
/// Types and overdue are combined with OR when both are present.
/// Filters by stored <paramref name="types"/> (including <see cref="WorkOrderType.Overdue"/> and
/// <see cref="WorkOrderType.Other"/>) and/or past-due rows when <paramref name="overdue"/> is true.
/// Past due is a status overlay, so types and overdue are combined with AND when both are present.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query,
@ -94,8 +94,8 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn));
&& ((w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn)));
}
return query.Where(w =>

View file

@ -57,6 +57,7 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Overdue && "overdue".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))

View file

@ -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<TeamMemberInviteData?> 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<bool> 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<bool> 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<bool> 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<bool> 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<TeamMemberInvite> OpenInvite(int inviteId, DateTime now)
{
return _context.TeamMemberInvites.Where(invite => invite.Id == inviteId
&& invite.UsedAt == null
&& invite.RevokedAt == null
&& invite.ExpiresAt > now);
}
}
}

View file

@ -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);

View file

@ -0,0 +1,30 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Dto;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// 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.
/// </summary>
public interface ITeamMemberInviteDataService
{
Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken);
/// <summary>Revokes every open invite for the invite's user and adds the new one, atomically.</summary>
Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken);
Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken);
Task<bool> TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken);
/// <summary>Counts one verification attempt against a live code; false once the attempt limit is used up.</summary>
Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken);
Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken);
/// <summary>Marks an open, unexpired, email-confirmed invite as used; false when another request already did.</summary>
Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken);
}
}

View file

@ -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<string> ServiceAreas { get; init; }
public bool PendingRegistration { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? InviteEmailSent { get; init; }
}
public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO

View file

@ -0,0 +1,71 @@
namespace SeaHaven.Services.DTOs;
/// <summary>A freshly issued invite. The raw token is never persisted and never printed.</summary>
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; }
}

View file

@ -10,7 +10,7 @@ namespace SeaHaven.Services.DTOs
public List<string>? Dispatchers { get; set; }
public bool MyWorkOrders { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
/// <summary>When true, keep only past-due (isPastDue) rows. Combined with Types via AND.</summary>
public bool Overdue { get; set; }
public string? Search { get; set; }
}

View file

@ -98,7 +98,7 @@ namespace SeaHaven.Services.DTOs
public List<string>? Sites { get; set; }
public List<string>? Regions { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
/// <summary>When true, keep only past-due (isPastDue) rows. Combined with Types via AND.</summary>
public bool Overdue { get; set; }
public List<string>? Dispatchers { get; set; }
public List<LifecycleStatus>? Statuses { get; set; }

View file

@ -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<FrontendOptions>(configuration);
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));

View file

@ -21,9 +21,7 @@ public static class DashboardMetrics
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
var completed = status == LifecycleStatus.Completed;
var canceled = status == LifecycleStatus.Canceled;
var pastDue = IsPastDue(workOrder, status, today);
AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue);
AddBreakdownCount(breakdown, workOrder.WorkOrderType);
var due = completed || (workOrder.ScheduledDate is DateTime scheduled
&& DateOnly.FromDateTime(scheduled.Date) <= today);
@ -81,25 +79,11 @@ public static class DashboardMetrics
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
.ToList();
private static bool IsPastDue(
DashboardWorkOrder workOrder,
LifecycleStatus? status,
DateOnly today)
=> workOrder.ScheduledDate is DateTime scheduled
&& DateOnly.FromDateTime(scheduled.Date) < today
&& status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled;
// Partition by the stored type only; past due is an overlay on any type, not a bucket.
private static void AddBreakdownCount(
DashboardBreakdownDTO breakdown,
WorkOrderType? type,
bool pastDue)
WorkOrderType? type)
{
if (pastDue)
{
breakdown.Overdue++;
return;
}
switch (type)
{
case WorkOrderType.PM:
@ -111,6 +95,9 @@ public static class DashboardMetrics
case WorkOrderType.Reactive:
breakdown.Reactive++;
break;
case WorkOrderType.Overdue:
breakdown.Overdue++;
break;
default:
breakdown.Other++;
break;

View file

@ -0,0 +1,62 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Generation and hashing for invite tokens and email confirmation codes. Raw
/// values exist only in memory and in the email sent to the invitee.
/// </summary>
public static class TeamMemberInviteSecrets
{
/// <summary>256 bits from the OS CSPRNG.</summary>
public const int TokenBytes = 32;
/// <summary>Upper bound on an accepted token string; a 32-byte base64url token is 43 characters.</summary>
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();
}
}
}

View file

@ -0,0 +1,17 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// The services registry and completion-doc templates are keyed on PM, Reactive and Emergency.
/// An Overdue work order is PM work the client never confirmed, so it uses the PM catalog.
/// </summary>
public static class WorkOrderCatalogType
{
public static WorkOrderType For(WorkOrderType type)
=> type == WorkOrderType.Overdue ? WorkOrderType.PM : type;
public static WorkOrderType? For(WorkOrderType? type)
=> type.HasValue ? For(type.Value) : null;
}
}

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
/// <summary>Board FE types. Overdue is a dispatcher-assigned type; past due is derived — see <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
@ -20,7 +20,7 @@ namespace SeaHaven.Services.Helpers
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
"overdue" => false,
"overdue" => Set(WorkOrderType.Overdue, out type),
_ => Enum.TryParse(label, true, out type)
};
}
@ -30,6 +30,7 @@ namespace SeaHaven.Services.Helpers
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
WorkOrderType.Overdue => "Overdue",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",

View file

@ -54,7 +54,16 @@ namespace SeaHaven.Services.Implementation
{
var user = await _userManager.FindByNameAsync(username ?? "");
if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? ""))
return await CreateSessionAsync(user, cancellationToken);
return null;
}
public async Task<LoginResultDTO> CreateSessionAsync(ApplicationUser user, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
cancellationToken.ThrowIfCancellationRequested();
var userRoles = await _userManager.GetRolesAsync(user);
var authClaims = new List<Claim>
{
@ -87,14 +96,11 @@ namespace SeaHaven.Services.Implementation
Email = user.Email,
UserRole = userRoles.FirstOrDefault(),
PhoneNumber = user.PhoneNumber,
Fullname = user.FirstName + " " + user.LastName,
Fullname = $"{user.FirstName} {user.LastName}".Trim(),
Id = user.Id
};
}
return null;
}
public async Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

View file

@ -5,6 +5,7 @@ using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -34,7 +35,11 @@ namespace SeaHaven.Services.Implementation
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var rows = await _templateData.ListAsync(search, serviceKey, workOrderType, cancellationToken);
var rows = await _templateData.ListAsync(
search,
serviceKey,
WorkOrderCatalogType.For(workOrderType),
cancellationToken);
return rows.Select(Map).ToList();
}

View file

@ -5,6 +5,7 @@ using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -34,8 +35,9 @@ namespace SeaHaven.Services.Implementation
public async Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
ValidateWorkOrderTypeFilter(workOrderType);
var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken);
var catalogType = WorkOrderCatalogType.For(workOrderType);
ValidateWorkOrderTypeFilter(catalogType);
var rows = await _dataService.GetServiceRowsAsync(isActive, catalogType, cancellationToken);
return rows.Select(MapToDto).ToList();
}

View file

@ -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
{
/// <summary>The prototype states no lifetime; seven days is the product decision.</summary>
public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7);
private readonly ITeamMemberInviteDataService _inviteDataService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IEmailSender _emailSender;
private readonly FrontendOptions _frontendOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TeamMemberInviteService> _logger;
public TeamMemberInviteService(
ITeamMemberInviteDataService inviteDataService,
UserManager<ApplicationUser> userManager,
IEmailSender emailSender,
IOptions<FrontendOptions> frontendOptions,
TimeProvider timeProvider,
ILogger<TeamMemberInviteService> logger)
{
_inviteDataService = inviteDataService;
_userManager = userManager;
_emailSender = emailSender;
_frontendOptions = frontendOptions.Value;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<IssuedTeamMemberInvite> CreateAsync(string userId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var (invite, issued) = NewInvite(userId);
await _inviteDataService.AddAsync(invite, cancellationToken);
return issued;
}
public async Task<bool> SendAsync(
IssuedTeamMemberInvite invite,
string email,
string name,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
cancellationToken.ThrowIfCancellationRequested();
var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}";
var body =
$"<p>Hi {WebUtility.HtmlEncode(name)},</p>" +
"<p>You've been added to Seahaven. Set your password and confirm your email to finish creating your account.</p>" +
// clicktracking=off stops SendGrid rewriting the link, so the token never passes through its redirect.
$"<p><a clicktracking=off href=\"{WebUtility.HtmlEncode(link)}\">Finish registration</a></p>" +
$"<p>This link expires in {InviteLifetime.Days} days and can be used once.</p>";
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<TeamMemberInviteResendOutcomeDTO> 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<bool?> 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 };
}

View file

@ -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<ApplicationUser> _userManager;
private readonly IAuthenticationService _authenticationService;
private readonly IEmailSender _emailSender;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TeamMemberRegistrationService> _logger;
public TeamMemberRegistrationService(
ITeamMemberInviteDataService inviteDataService,
IUserDataService userDataService,
UserManager<ApplicationUser> userManager,
IAuthenticationService authenticationService,
IEmailSender emailSender,
TimeProvider timeProvider,
ILogger<TeamMemberRegistrationService> logger)
{
_inviteDataService = inviteDataService;
_userDataService = userDataService;
_userManager = userManager;
_authenticationService = authenticationService;
_emailSender = emailSender;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<TeamMemberRegistrationOutcomeDTO> 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<TeamMemberRegistrationOutcomeDTO> 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 =
$"<p>Your Seahaven confirmation code is <strong>{code}</strong>.</p>" +
$"<p>It expires in {CodeLifetime.TotalMinutes:0} minutes. If you didn't request it, you can ignore this email.</p>";
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<TeamMemberRegistrationOutcomeDTO> 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<TeamMemberRegistrationOutcomeDTO> 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<RegistrationContext?> 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; }
}
}

View file

@ -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<ApplicationUser> 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<CreateTeamMemberOutcomeDTO> 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<TeamMemberOperationOutcomeDTO> 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<TeamPermissionValueDTO>()

View file

@ -298,7 +298,8 @@ namespace SeaHaven.Services.Implementation
return null;
}
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType))
var catalogType = WorkOrderCatalogType.For(workOrderType);
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType))
{
if (serviceId.HasValue)
throw new WorkOrderBoardValidationException(
@ -307,7 +308,7 @@ namespace SeaHaven.Services.Implementation
return null;
}
var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken);
var services = await _servicesRegistryService.GetAllAsync(true, catalogType, cancellationToken);
if (serviceId.HasValue)
{
var selected = services.FirstOrDefault(service => service.Id == serviceId.Value);

View file

@ -147,7 +147,12 @@ namespace SeaHaven.Services.Implementation
MediaCount = row.MediaCount,
CompletedDate = row.CompletedDate,
FlagColor = row.FlagColor,
Severity = WorkOrderSeverityRules.ParseLevel(row.Severity),
// Overdue never carries a severity. A standalone severity patch can still
// store one (the board patches severity before type when correcting Overdue
// to Emergency/Reactive), so the row never returns it for an Overdue WO.
Severity = row.WorkOrderType == WorkOrderType.Overdue
? null
: WorkOrderSeverityRules.ParseLevel(row.Severity),
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,

View file

@ -370,11 +370,6 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Overdue is not a work order type; it is derived from dueDate (isPastDue).");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
@ -426,10 +421,11 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)
var catalogType = WorkOrderCatalogType.For(workOrder.WorkOrderType ?? WorkOrderType.PM);
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType)
|| service == null
|| !service.IsActive
|| !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM))
|| !service.SupportedWorkOrderTypes.Contains(catalogType))
{
throw new WorkOrderBoardValidationException(
"ServiceInvalid",
@ -458,7 +454,7 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType))
if (service == null || !service.SupportedWorkOrderTypes.Contains(WorkOrderCatalogType.For(workOrderType)))
throw new WorkOrderBoardValidationException(
"ServiceTypeInvalid",
"The selected service does not support this work order type.");
@ -467,7 +463,6 @@ namespace SeaHaven.Services.Implementation
private static WorkOrderType ParseWorkOrderType(string? value)
{
if (string.IsNullOrWhiteSpace(value)
|| string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)
|| !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
{
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");

View file

@ -93,7 +93,9 @@ namespace SeaHaven.Services.Implementation
await _completionData.SaveAsync(cancellationToken);
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
var template = await _templateData.ResolveForWorkOrderAsync(
workOrder.Trade,
WorkOrderCatalogType.For(workOrder.WorkOrderType));
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
return new WorkOrderCompletionDto

View file

@ -48,7 +48,7 @@ namespace SeaHaven.Services.Implementation
var template = await _templateData.ResolveForWorkOrderAsync(
extended?.Trade ?? boardRow.Pm,
boardRow.WorkOrderType);
WorkOrderCatalogType.For(boardRow.WorkOrderType));
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);

View file

@ -5,6 +5,8 @@ namespace SeaHaven.Services.Interfaces
public interface IAuthenticationService
{
Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken);
/// <summary>Builds the same signed session payload that a successful login returns.</summary>
Task<LoginResultDTO> CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken);
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
/// <summary>

View file

@ -0,0 +1,31 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces;
public interface ITeamMemberInviteService
{
/// <summary>Persists a new invite for a pending member and returns its raw token.</summary>
Task<IssuedTeamMemberInvite> CreateAsync(string userId, CancellationToken cancellationToken);
/// <summary>Emails the invite link. Returns false when the email could not be handed off.</summary>
Task<bool> SendAsync(
IssuedTeamMemberInvite invite,
string email,
string name,
CancellationToken cancellationToken);
/// <summary>
/// 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.
/// </summary>
Task<bool?> ReissueAsync(ApplicationUser user, CancellationToken cancellationToken);
/// <summary>Admin-only: revokes a pending member's open invites and emails a new one.</summary>
Task<TeamMemberInviteResendOutcomeDTO> ResendAsync(
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
}

View file

@ -0,0 +1,23 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces;
/// <summary>
/// The anonymous invite-registration flow. Every operation is scoped by the invite
/// token alone; unknown, expired, used and revoked invites share one outcome.
/// </summary>
public interface ITeamMemberRegistrationService
{
Task<TeamMemberRegistrationOutcomeDTO> ResolveAsync(string? token, CancellationToken cancellationToken);
Task<TeamMemberRegistrationOutcomeDTO> SendCodeAsync(string? token, CancellationToken cancellationToken);
Task<TeamMemberRegistrationOutcomeDTO> VerifyCodeAsync(
string? token,
string? code,
CancellationToken cancellationToken);
Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(
CompleteTeamMemberRegistrationRequestDTO request,
CancellationToken cancellationToken);
}

View file

@ -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<ITeamMemberService>();
@ -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<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddSingleton<IEmailSender>(emailSender ?? new CapturingEmailSender());
services.AddDataServices();
services.AddBusinessServices(configuration);

View file

@ -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;
/// <summary>What happens to a member's invites when an admin edits or deletes that member.</summary>
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<IUserService>()
.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<ApplicationDbContext>()
.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<TeamMemberOperationOutcomeDTO> UpdateAsync(
TeamMemberInviteTestHost host,
string userId,
string email,
string role,
bool isActive = true) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().UpdateAsync(
userId,
new UpdateTeamMemberRequestDTO
{
Name = "Taylor Reed",
Role = role,
Color = "#0D9488",
Email = email,
Phone = "555-0100",
ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty<string>(),
IsActive = isActive
},
TeamMemberInviteTestHost.Admin(),
CancellationToken.None));
private static Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(TeamMemberInviteTestHost host, string token) =>
host.RegistrationAsync(service => service.CompleteAsync(
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password },
CancellationToken.None));
private static Task<TeamMemberInviteResendOutcomeDTO> ResendInviteAsync(TeamMemberInviteTestHost host, string userId) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
.ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
}

View file

@ -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("<a clicktracking=off href=", sent.Body);
}
[Fact]
public async Task FullFlow_FromEmailedToken_ActivatesMemberConfirmsEmailAndSignsIn()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email, "Taylor Reed");
var resolved = await host.RegistrationAsync(service => 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<UserManager<ApplicationUser>>().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<ApplicationDbContext>().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<string>
{
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<ITeamMemberInviteDataService>()
.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<ITeamMemberInviteDataService>(provider =>
new ClosesInviteBeforeCodeStart(
new TeamMemberInviteDataService(provider.GetRequiredService<ApplicationDbContext>()),
provider.GetRequiredService<ApplicationDbContext>(),
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);
}
/// <summary>Closes the invite after the service has read it and before the conditional code start runs.</summary>
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<bool> 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<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) =>
_inner.GetByTokenHashAsync(tokenHash, cancellationToken);
public Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken) =>
_inner.TryReserveCodeAttemptAsync(inviteId, maxAttempts, now, cancellationToken);
public Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken) =>
_inner.TryConfirmEmailAsync(inviteId, codeHash, now, cancellationToken);
public Task<bool> 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<IPasswordValidator<ApplicationUser>, FailingPasswordValidator>());
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
var failure = await Assert.ThrowsAsync<InvalidOperationException>(() => 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);
}
/// <summary>Stands in for any Identity failure that is not the password rule itself.</summary>
private sealed class FailingPasswordValidator : IPasswordValidator<ApplicationUser>
{
public const string Code = "ConcurrencyFailure";
public Task<IdentityResult> ValidateAsync(UserManager<ApplicationUser> 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<ITeamMemberService>().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<ITeamMemberInviteService>()
.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<OperationCanceledException>(() =>
host.RegistrationAsync(service => service.SendCodeAsync(token, cancelled.Token)));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 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<TeamMemberRegistrationOutcomeDTO> VerifyAsync(TeamMemberInviteTestHost host, string token, string code) =>
host.RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None));
private static Task<TeamMemberRegistrationOutcomeDTO> 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<TeamMemberInviteResendOutcomeDTO> ResendInviteAsync(TeamMemberInviteTestHost host, string userId) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
.ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
private static async Task<string> 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<TeamMemberInviteController, Task<IActionResult>> action) =>
host.InScopeAsync(async provider =>
{
var httpContext = new DefaultHttpContext();
var controller = new TeamMemberInviteController(provider.GetRequiredService<ITeamMemberRegistrationService>())
{
ControllerContext = new ControllerContext { HttpContext = httpContext }
};
var result = Assert.IsAssignableFrom<ObjectResult>(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('/', '_');
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<TeamMemberInviteTestHost> CreateAsync(Action<IServiceCollection>? configure = null)
{
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().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<IServiceCollection>? configure)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<ApplicationDbContext>(builder => builder.UseSqlite(connection));
services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqliteInviteTestDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
services.AddSeaHavenIdentity();
services.AddSingleton<TimeProvider>(fakes.Time);
services.AddSingleton<IEmailSender>(fakes.Sent);
services.AddDataServices();
services.AddBusinessServices(configuration);
configure?.Invoke(services);
return services.BuildServiceProvider();
}
public async Task<T> InScopeAsync<T>(Func<IServiceProvider, Task<T>> action)
{
await using var scope = _provider.CreateAsyncScope();
return await action(scope.ServiceProvider);
}
public Task<TeamMemberRegistrationOutcomeDTO> RegistrationAsync(
Func<ITeamMemberRegistrationService, Task<TeamMemberRegistrationOutcomeDTO>> action) =>
InScopeAsync(provider => action(provider.GetRequiredService<ITeamMemberRegistrationService>()));
/// <summary>Creates a pending member through the admin create path and returns the emailed token.</summary>
public async Task<(string UserId, string Token)> AddPendingMemberAsync(string email, string name = "Taylor Reed")
{
var outcome = await InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().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<string> 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<ApplicationUser> ReloadUserAsync(string userId) =>
InScopeAsync(async provider => await provider.GetRequiredService<ApplicationDbContext>()
.Users.AsNoTracking().SingleAsync(user => user.Id == userId));
public Task<List<TeamMemberInvite>> InvitesAsync(string userId) =>
InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>()
.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<ApplicationDbContext> 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<SentEmail> _messages = new();
public bool Succeeds { get; set; } = true;
public IReadOnlyList<SentEmail> Messages => _messages.ToArray();
public Task<bool> 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#(?<token>[A-Za-z0-9_-]+)")]
public static partial Regex InviteLink();
[GeneratedRegex("<strong>(?<code>[0-9]{6})</strong>")]
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<string> _entries = new();
public IReadOnlyList<string> Entries => _entries.ToArray();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(_entries);
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly ConcurrentQueue<string> _entries;
public CapturingLogger(ConcurrentQueue<string> entries) => _entries = entries;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
_entries.Enqueue(state.ToString() ?? "");
return null;
}
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
var values = state is IEnumerable<KeyValuePair<string, object?>> pairs
? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}"))
: "";
_entries.Enqueue($"{formatter(state, exception)} {values} {exception}");
}
}
}

View file

@ -601,7 +601,7 @@ public class WorkOrderAdvancedSearchServiceTests
}
[Fact]
public async Task SearchAsync_OtherPlusOverdue_ReturnsUnion()
public async Task SearchAsync_OtherPlusOverdue_ReturnsIntersection()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
@ -610,7 +610,7 @@ public class WorkOrderAdvancedSearchServiceTests
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
WorkOrderType = WorkOrderType.Other,
DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
@ -648,10 +648,11 @@ public class WorkOrderAdvancedSearchServiceTests
Overdue = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
// Past due is a status overlay: it narrows the type filter instead of widening it.
var row = Assert.Single(result.Items);
Assert.Equal(1, row.Id);
Assert.True(row.IsPastDue);
Assert.Equal(WorkOrderType.Other, row.WorkOrderType);
}
[Fact]

View file

@ -581,7 +581,7 @@ public class WorkOrderBoardUpdateServiceTests
}
[Fact]
public async Task PatchField_RejectsOverdueAsWorkOrderType()
public async Task PatchField_AcceptsOverdueAsWorkOrderType()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
@ -594,17 +594,15 @@ public class WorkOrderBoardUpdateServiceTests
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
}, "actor-1");
Assert.Equal("InvalidValue", ex.Code);
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Equal(WorkOrderType.Overdue, (await context.workOrders.SingleAsync(w => w.Id == 1)).WorkOrderType);
}
[Fact]

View file

@ -0,0 +1,509 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
// Overdue is a dispatcher-assigned work-order type: no severity, PM catalog
// (services and completion-doc templates), and distinct from the derived
// past-due overlay.
public class WorkOrderOverdueTypeTests
{
private const int PmServiceId = 10;
private const int ReactiveServiceId = 11;
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static void SeedServices(ApplicationDbContext context)
{
context.Services.AddRange(
NewService(PmServiceId, "Backflow Preventers", WorkOrderType.PM),
NewService(ReactiveServiceId, "Leak Repair", WorkOrderType.Reactive));
}
private static Service NewService(int id, string name, WorkOrderType type) => new()
{
Id = id,
Name = name,
NormalizedName = name.ToUpperInvariant(),
Trade = "Plumbing & Water Systems",
IconKey = "plumbing-water-systems",
IsActive = true,
SupportedWorkOrderTypes = new List<ServiceWorkOrderType>
{
new() { WorkOrderType = type }
}
};
private static WorkOrderBoardCreateService NewCreateService(ApplicationDbContext context)
{
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardCreateService(
boardData,
new WorkOrderBoardMutationDataService(context),
new WorkOrderBoardService(boardData, resolver),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context),
new ServicesRegistryService(new ServicesRegistryDataService(context)));
}
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new ServicesRegistryService(new ServicesRegistryDataService(context)));
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Fact]
public async Task Create_Overdue_WithoutSeverity_PersistsOverdueType()
{
await using var context = NewContext();
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
var persisted = await context.workOrders.SingleAsync();
Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
Assert.Equal(8, (int)persisted.WorkOrderType!.Value);
}
[Fact]
public async Task Create_Overdue_DropsSuppliedSeverity()
{
await using var context = NewContext();
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
Severity = 2
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Null(result.Severity);
Assert.Null((await context.workOrders.SingleAsync()).Severity);
}
[Fact]
public async Task Create_Overdue_AcceptsPmCatalogService()
{
await using var context = NewContext();
SeedServices(context);
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
ServiceId = PmServiceId
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(PmServiceId, result.ServiceId);
Assert.Equal("Backflow Preventers", (await context.workOrders.SingleAsync()).ServiceNameSnapshot);
}
[Fact]
public async Task Create_Overdue_RejectsReactiveOnlyService()
{
await using var context = NewContext();
SeedServices(context);
var service = NewCreateService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
ServiceId = ReactiveServiceId
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ServiceInvalid", ex.Code);
}
[Fact]
public async Task PatchType_ToOverdue_ClearsSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Reactive,
Severity = "3",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
Assert.Null(persisted.Severity);
}
// The board sends one request per field, severity before type, so correcting
// Overdue to Emergency patches severity while the stored type is still Overdue.
// A severity patch must not be dropped or rejected for the current type; the
// no-severity invariant is enforced when the type itself changes.
[Fact]
public async Task PatchSeverityThenType_OverdueToEmergency_KeepsSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var service = NewUpdateService(context);
var severityStep = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "2",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(severityStep.Severity);
var updated = await context.workOrders.SingleAsync(w => w.Id == 1);
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Emergency",
WorkOrderVersion = ToVersion(updated)
}, "actor-1");
Assert.Equal(WorkOrderType.Emergency, result.WorkOrderType);
Assert.Equal(2, result.Severity);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Emergency, persisted.WorkOrderType);
Assert.Equal("2", persisted.Severity);
}
// A severity patch with no following type change leaves a stored value on an
// Overdue WO; the board row still reports no severity.
[Fact]
public async Task PatchSeverity_OnOverdue_RowShowsNoSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "3",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
}
[Fact]
public async Task PatchType_PmWithPmService_ToOverdue_KeepsService()
{
await using var context = NewContext();
SeedServices(context);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
ServiceId = PmServiceId,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Equal(PmServiceId, (await context.workOrders.SingleAsync(w => w.Id == 1)).ServiceId);
}
[Fact]
public async Task PatchServiceId_OnOverdue_AcceptsPmCatalogService()
{
await using var context = NewContext();
SeedServices(context);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ServiceId,
Value = PmServiceId.ToString(),
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(PmServiceId, persisted.ServiceId);
Assert.Equal("Backflow Preventers", persisted.Trade);
}
[Fact]
public async Task GetDetail_Overdue_ResolvesPmCompletionTemplate()
{
await using var context = NewContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = DateTime.UtcNow.AddDays(1),
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Name = "PM Completion",
ServiceKey = "pm-generic",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/pm.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var service = new WorkOrderDetailService(
new WorkOrderBoardService(boardData, resolver),
new WorkOrderDetailDataService(context),
new CompletionDocTemplateDataService(context),
new UserDataService(context),
resolver);
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(DocStatus.No, detail!.Completion.DocStatus);
Assert.NotNull(detail.Completion.Template);
Assert.Equal("PM Completion", detail.Completion.Template!.Name);
}
[Fact]
public async Task ListServices_Overdue_ReturnsPmCatalog()
{
await using var context = NewContext();
SeedServices(context);
await context.SaveChangesAsync();
var services = await new ServicesRegistryService(new ServicesRegistryDataService(context))
.GetAllAsync(true, WorkOrderType.Overdue, CancellationToken.None);
Assert.Equal(new[] { PmServiceId }, services.Select(s => s.Id).ToArray());
}
[Fact]
public async Task ListTemplates_Overdue_ReturnsPmTemplates()
{
await using var context = NewContext();
context.CompletionDocTemplates.AddRange(
new CompletionDocTemplate
{
Name = "PM Completion",
ServiceKey = "pm-generic",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/pm.pdf",
IsActive = true
},
new CompletionDocTemplate
{
Name = "Reactive Completion",
ServiceKey = "reactive-generic",
WorkOrderType = WorkOrderType.Reactive,
TemplateUrl = "https://example.com/reactive.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var service = new CompletionDocTemplateService(
new CompletionDocTemplateDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
WorkOrderAccountTestHelpers.Resolver(context));
var templates = await service.ListAsync(null, null, WorkOrderType.Overdue, CancellationToken.None);
Assert.Equal(new[] { "PM Completion" }, templates.Select(t => t.Name).ToArray());
}
[Fact]
public async Task Search_TypeOverdue_ReturnsOverdueTypeOnly_NotPastDueRows()
{
await using var context = NewContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Overdue }
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id));
Assert.All(result.Items, i => Assert.Equal(WorkOrderType.Overdue, i.WorkOrderType));
}
[Fact]
public async Task Search_TypeOverdueWithPastDue_ReturnsIntersection()
{
await using var context = NewContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Overdue },
Overdue = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
var row = Assert.Single(result.Items);
Assert.Equal(3, row.Id);
Assert.True(row.IsPastDue);
}
[Fact]
public void DashboardBreakdown_CountsByTypeOnly()
{
var today = new DateOnly(2026, 9, 25);
var pastDue = today.AddDays(-3).ToDateTime(TimeOnly.MinValue);
var future = today.AddDays(3).ToDateTime(TimeOnly.MinValue);
var rows = new List<DashboardWorkOrder>
{
new(1, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, future, null, null),
new(2, null, WorkOrderType.PM, LifecycleStatus.Scheduled, null, pastDue, null, null),
new(3, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, pastDue, null, null),
};
var result = DashboardMetrics.Calculate(rows, today);
Assert.Equal(2, result.Breakdown.Overdue);
Assert.Equal(1, result.Breakdown.PM);
Assert.Equal(0, result.Breakdown.Other);
}
private static WorkOrderAdvancedSearchService NewSearchService(ApplicationDbContext context)
=> new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
}

View file

@ -10,21 +10,18 @@ public class WorkOrderTypeMapperTests
[InlineData("Reactive", WorkOrderType.Reactive)]
[InlineData("Emergency", WorkOrderType.Emergency)]
[InlineData("Add-On", WorkOrderType.AddOn)]
[InlineData("Overdue", WorkOrderType.Overdue)]
[InlineData("overdue", WorkOrderType.Overdue)]
public void TryParseFeLabel_BoardTypes(string label, WorkOrderType expected)
{
Assert.True(WorkOrderTypeMapper.TryParseFeLabel(label, out var type));
Assert.Equal(expected, type);
}
[Fact]
public void TryParseFeLabel_Overdue_ReturnsFalse()
{
Assert.False(WorkOrderTypeMapper.TryParseFeLabel("Overdue", out _));
}
[Theory]
[InlineData(WorkOrderType.AddOn, "Add-On")]
[InlineData(WorkOrderType.Reactive, "Reactive")]
[InlineData(WorkOrderType.Overdue, "Overdue")]
public void ToFeLabel_ReturnsBoardLabel(WorkOrderType type, string expected)
{
Assert.Equal(expected, WorkOrderTypeMapper.ToFeLabel(type));