diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs index 516897a..f3ec64b 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs @@ -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(); + + filter.Should().NotBeNull(); + filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter)); + } + + [Fact] + public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail() + { + var context = ExceptionContextFor(new ArgumentException( + "SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'")); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeTrue(); + var result = context.Result.Should().BeOfType().Subject; + result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + var body = JsonSerializer.Serialize(result.Value); + body.Should().Be( + "{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}"); + body.Should().NotContain("SELECT").And.NotContain("leak-me"); + } + + [Fact] + public void ExceptionFilter_LeavesCancellationToTheHost() + { + var context = ExceptionContextFor(new OperationCanceledException()); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeFalse(); + context.Result.Should().BeNull(); + } + + private static ExceptionContext ExceptionContextFor(Exception exception) + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + return new ExceptionContext(actionContext, new List()) { Exception = exception }; + } + [Theory] [InlineData(TeamMemberRegistrationStatus.InvalidInvite)] [InlineData(TeamMemberRegistrationStatus.Ok)] diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs index bd2d161..af16db7 100644 --- a/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs @@ -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 = diff --git a/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs new file mode 100644 index 0000000..93b9b00 --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace Api.SeaHavenIndustries.Filters +{ + /// + /// Anonymous invite endpoints never echo an exception: controller-scoped exception + /// filters run before the global ones, so no message, stack or SQL reaches the caller. + /// Cancellation is left to the host. + /// + public sealed class InviteRegistrationExceptionFilter : IExceptionFilter + { + public const string Message = "Something went wrong. Try again in a minute."; + + private readonly ILogger _logger; + + public InviteRegistrationExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + if (context.Exception is OperationCanceledException) + return; + + _logger.LogError(context.Exception, "Invite registration request failed."); + + context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + context.ExceptionHandled = true; + } + } +} diff --git a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs index 814e279..3a830fe 100644 --- a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs +++ b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Data.SeaHavenIndustries.Enums; namespace SeaHaven.Services.DTOs; @@ -23,6 +24,10 @@ public class TeamMemberCreatedDTO public string? Phone { get; init; } public required IReadOnlyList ServiceAreas { get; init; } public bool PendingRegistration { get; init; } + + /// Set only on create: false means the member exists but the invite email failed; re-invite from their details. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? InviteEmailSent { get; init; } } public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 5df17de..bc7663c 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -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 }; } diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs index 7577507..330254b 100644 --- a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs @@ -62,7 +62,8 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService var body = $"

Hi {WebUtility.HtmlEncode(name)},

" + "

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

" + - $"

Finish registration

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

Finish registration

" + $"

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

"; var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body); diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs index 2bc306c..c64e119 100644 --- a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -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 ?? ""); diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index 6a25aef..2f62818 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -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 } }; } diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs index 11632c4..dac402a 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -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(" provider.GetRequiredService().CreateAsync( + new CreateTeamMemberRequestDTO + { + Name = "Taylor Reed", + Role = "dispatcher", + Color = "#0D9488", + Email = Email, + ServiceAreas = new[] { "East" } + }, + TeamMemberInviteTestHost.Admin(), + CancellationToken.None)); + + Assert.True(outcome.Success); + Assert.Equal(delivered, outcome.Member!.InviteEmailSent); + Assert.Single(await host.InvitesAsync(outcome.Member.Id)); + } + [Fact] public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite() {