fix(team-members): keep omitted phone, report invite email failures, never echo invite errors

This commit is contained in:
Alexandre Brandizzi 2026-09-25 12:11:52 -03:00
parent c0ae8479ce
commit 385229c64d
9 changed files with 164 additions and 6 deletions

View file

@ -1,5 +1,11 @@
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;
@ -44,6 +50,51 @@ public class TeamMemberInviteControllerTests
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)]

View file

@ -1,4 +1,5 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
@ -14,6 +15,7 @@ namespace Api.SeaHavenIndustries.Controllers;
[ApiController]
[Route("api/team-member-invites")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[TypeFilter(typeof(InviteRegistrationExceptionFilter))]
public sealed class TeamMemberInviteController : ControllerBase
{
public const string InvalidInviteMessage =

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

@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs;
@ -23,6 +24,10 @@ public class TeamMemberCreatedDTO
public string? Phone { get; init; }
public required IReadOnlyList<string> ServiceAreas { get; init; }
public bool PendingRegistration { get; init; }
/// <summary>Set only on create: false means the member exists but 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

@ -81,7 +81,7 @@ 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
};
}

View file

@ -62,7 +62,8 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService
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>" +
$"<p><a href=\"{WebUtility.HtmlEncode(link)}\">Finish registration</a></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);

View file

@ -149,6 +149,8 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat
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);
@ -165,8 +167,11 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat
user.EmailConfirmed = true;
user.PendingRegistration = false;
user.UniqueName = "Active";
user.PhoneNumber = phone;
user.Contact = phone;
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 ?? "");

View file

@ -115,7 +115,7 @@ public sealed class TeamMemberService : ITeamMemberService
// The member and their invite commit together; the email goes out only after
// commit, so a rolled-back member never receives a link.
await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
var inviteEmailSent = await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
return new CreateTeamMemberOutcomeDTO
{
@ -129,7 +129,8 @@ public sealed class TeamMemberService : ITeamMemberService
Email = user.Email,
Phone = user.PhoneNumber,
ServiceAreas = areas!,
PendingRegistration = true
PendingRegistration = true,
InviteEmailSent = inviteEmailSent
}
};
}

View file

@ -37,6 +37,7 @@ public sealed class TeamMemberInviteRegistrationTests
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]
@ -58,6 +59,7 @@ public sealed class TeamMemberInviteRegistrationTests
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);
@ -290,6 +292,59 @@ public sealed class TeamMemberInviteRegistrationTests
Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration);
}
[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()
{