feat(team-members): report whether the re-invite after an email change was emailed

This commit is contained in:
Alexandre Brandizzi 2026-09-25 12:54:27 -03:00
parent f491d4c721
commit b50cd5f5df
3 changed files with 26 additions and 4 deletions

View file

@ -25,7 +25,10 @@ public class TeamMemberCreatedDTO
public required IReadOnlyList<string> ServiceAreas { get; init; } public required IReadOnlyList<string> ServiceAreas { get; init; }
public bool PendingRegistration { 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> /// <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)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? InviteEmailSent { get; init; } public bool? InviteEmailSent { get; init; }
} }

View file

@ -239,10 +239,11 @@ public sealed class TeamMemberService : ITeamMemberService
} }
// An invite sent to the old address, and any code confirmed through it, must not register the new one. // 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) if (emailChanged && user.PendingRegistration == true)
await _inviteService.ReissueAsync(user, cancellationToken); inviteEmailSent = await _inviteService.ReissueAsync(user, cancellationToken);
return await OperationSuccessAsync(user, caller, cancellationToken); return await OperationSuccessAsync(user, caller, cancellationToken, inviteEmailSent);
} }
private static void ApplyName(ApplicationUser user, string fullName) private static void ApplyName(ApplicationUser user, string fullName)
@ -277,7 +278,8 @@ public sealed class TeamMemberService : ITeamMemberService
private async Task<TeamMemberOperationOutcomeDTO> OperationSuccessAsync( private async Task<TeamMemberOperationOutcomeDTO> OperationSuccessAsync(
ApplicationUser user, ApplicationUser user,
ClaimsPrincipal caller, ClaimsPrincipal caller,
CancellationToken cancellationToken) CancellationToken cancellationToken,
bool? inviteEmailSent = null)
{ {
var roles = await _userManager.GetRolesAsync(user); var roles = await _userManager.GetRolesAsync(user);
var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken); var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken);
@ -295,6 +297,7 @@ public sealed class TeamMemberService : ITeamMemberService
Phone = user.Contact ?? user.PhoneNumber, Phone = user.Contact ?? user.PhoneNumber,
ServiceAreas = areas, ServiceAreas = areas,
PendingRegistration = user.PendingRegistration == true, PendingRegistration = user.PendingRegistration == true,
InviteEmailSent = inviteEmailSent,
Status = user.UniqueName ?? "Active", Status = user.UniqueName ?? "Active",
IsAccountOwner = user.IsAccountOwner, IsAccountOwner = user.IsAccountOwner,
Permissions = profile?.Value?.Permissions ?? Array.Empty<TeamPermissionValueDTO>() Permissions = profile?.Value?.Permissions ?? Array.Empty<TeamPermissionValueDTO>()

View file

@ -40,6 +40,7 @@ public sealed class TeamMemberInviteLifecycleTests
var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin"); var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin");
Assert.True(updated.Success, updated.Error); Assert.True(updated.Success, updated.Error);
Assert.True(updated.Member!.InviteEmailSent);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status); (await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status);
@ -59,6 +60,20 @@ public sealed class TeamMemberInviteLifecycleTests
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, newToken)).Status); 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] [Fact]
public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite() public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite()
{ {
@ -69,6 +84,7 @@ public sealed class TeamMemberInviteLifecycleTests
var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher"); var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher");
Assert.True(updated.Success, updated.Error); Assert.True(updated.Success, updated.Error);
Assert.Null(updated.Member!.InviteEmailSent);
Assert.Equal(emailsBefore, host.Sent.Messages.Count); Assert.Equal(emailsBefore, host.Sent.Messages.Count);
var invite = Assert.Single(await host.InvitesAsync(userId)); var invite = Assert.Single(await host.InvitesAsync(userId));
Assert.Null(invite.RevokedAt); Assert.Null(invite.RevokedAt);