Replace shared email commands with MediatR notifications (#280)

This commit is contained in:
Aaron Po
2026-09-05 22:37:00 -04:00
committed by GitHub
parent 4aa7ba5d9b
commit d958933250
24 changed files with 322 additions and 218 deletions

View File

@@ -77,7 +77,7 @@ flowchart TB
subgraph SHARED["Shared Projects"] subgraph SHARED["Shared Projects"]
direction LR direction LR
SH1["Shared.Contracts<br/>ResponseBody envelope"] SH1["Shared.Contracts<br/>ResponseBody envelope"]
SH2["Shared.Application<br/>ValidationBehavior,<br/>cross-slice email commands"] SH2["Shared.Application<br/>ValidationBehavior"]
end end
subgraph DOMAIN["Domain Layer"] subgraph DOMAIN["Domain Layer"]
@@ -98,7 +98,7 @@ flowchart TB
DB[("Database (SQL Server)")] DB[("Database (SQL Server)")]
HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3 S3 == "ProjectReference<br/>(handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification)" ==> S1
S1 -- "ProjectReference<br/>(avatar upload)" --> S5 S1 -- "ProjectReference<br/>(avatar upload)" --> S5
SLICES --> SHARED SLICES --> SHARED
@@ -172,8 +172,10 @@ brewery photo upload command) rather than bound to an HTTP route directly.
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for `Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload) PhotoUpload)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with one exception: `Features.Users` references - No other `Features.*` project, with two exceptions: `Features.Users`
`Features.PhotoUpload` directly for avatar uploads references `Features.PhotoUpload` directly for avatar uploads, and
`Features.Emails` references `Features.Users` directly to handle
`UserRegisteredNotification` and `ConfirmationEmailResendRequestedNotification`
**Rules**: **Rules**:
@@ -191,7 +193,7 @@ needs it, or because duplicating it four times would be worse than sharing it
| Project | Purpose | | Project | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns | | `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns |
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) | | `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
**Rules**: **Rules**:

View File

@@ -127,9 +127,9 @@ sequenceDiagram
TokenSvc-->>RegHandler: Confirmation Token TokenSvc-->>RegHandler: Confirmation Token
deactivate TokenSvc deactivate TokenSvc
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract) RegHandler->>Mediator: Publish(UserRegisteredNotification)
activate Mediator activate Mediator
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the second of two cross-slice dispatches in this flow (profile creation is the first) Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via UserRegisteredNotificationHandler — the second of two cross-slice dispatches in this flow (profile creation is the first)
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken) Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher activate EmailDispatcher
@@ -147,15 +147,15 @@ sequenceDiagram
SMTP-->>EmailDispatcher: Success / throws SMTP-->>EmailDispatcher: Success / throws
deactivate SMTP deactivate SMTP
deactivate EmailDispatcher deactivate EmailDispatcher
deactivate Mediator
alt Email sent successfully alt Email sent successfully
RegHandler->>RegHandler: emailSent = true Note right of Mediator: UserRegisteredNotificationHandler completes normally
else mediator.Send throws else EmailDispatcher throws
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false Note right of Mediator: UserRegisteredNotificationHandler catches and logs the exception — registration is unaffected
end end
deactivate Mediator
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent) RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken)
deactivate RegHandler deactivate RegHandler
Mediator-->>API: RegistrationPayload Mediator-->>API: RegistrationPayload
deactivate Mediator deactivate Mediator

View File

@@ -51,13 +51,20 @@ sequenceDiagram
TokenSvc-->>ResendHandler: confirmation token TokenSvc-->>ResendHandler: confirmation token
deactivate TokenSvc deactivate TokenSvc
ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand) ResendHandler->>Mediator: Publish(ConfirmationEmailResendRequestedNotification)
activate Mediator activate Mediator
Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via ConfirmationEmailResendRequestedNotificationHandler
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken) Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher activate EmailDispatcher
EmailDispatcher->>Template: Render email (same Razor pipeline as registration) EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
EmailDispatcher->>SMTP: SendAsync(email, subject, body) EmailDispatcher->>SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher deactivate EmailDispatcher
alt Email sent successfully
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler completes normally
else EmailDispatcher throws
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler catches and logs the exception — the resend request is unaffected
end
deactivate Mediator deactivate Mediator
end end
end end

View File

@@ -16,13 +16,14 @@ doesn't wire up any slice; it applies schema directly.
## Project reference dependencies ## Project reference dependencies
Solid arrows are compile-time project references. Dashed arrows are the two Solid arrows are compile-time project references — every slice-to-slice
places slices talk to each other without a direct reference: the email MediatR dependency is one now that the email MediatR contract has moved off
contract routed through `Shared.Application`, and the one place that pattern is `Shared.Application`: `Features.Users` on `Features.PhotoUpload` for avatar
broken outright. Every DB-backed slice depends on the same three foundation uploads, and `Features.Emails` on `Features.Users` for the registration and
projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn resend-confirmation notifications. Every DB-backed slice depends on the same
as one box instead of fifteen separate arrows; see the notes for what else each three foundation projects (`Shared.Contracts`, `Domain.Entities`,
of those foundation projects pulls in. `Database.Connection`) — drawn as one box instead of fifteen separate arrows;
see the notes for what else each of those foundation projects pulls in.
```mermaid ```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd" --8<-- "web/diagrams/vertical-slice/project-references.mmd"
@@ -54,17 +55,20 @@ of those foundation projects pulls in.
- `Features.Beers` is scaffolded (project + references only, no source files - `Features.Beers` is scaffolded (project + references only, no source files
yet) yet)
- **The one exception to slice isolation**: `Features.Users` takes a direct - **The exceptions to slice isolation**:
project reference to `Features.PhotoUpload` - `Features.Users` takes a direct project reference to `Features.PhotoUpload`
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
- unlike the email flow, the command type lives in the target slice itself, - the command type lives in the target slice itself, not in
not in `Shared.Application` `Shared.Application`
- `Features.Emails` takes a direct project reference to `Features.Users`
- handles `UserRegisteredNotification` (published from
`RegisterUserHandler`) and `ConfirmationEmailResendRequestedNotification`
(published from `ResendConfirmationEmailHandler`)
- both notification types live in `Features.Users`
(`Features.Auth.Notifications`), not in `Shared.Application`
- **Shared.Application**: - **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline) - `ValidationBehavior` (MediatR pipeline)
- Cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
- MediatR command contract (not project reference)
- Every slice (including `Features.Emails`) references it for the pipeline - Every slice (including `Features.Emails`) references it for the pipeline
behavior alone behavior alone

View File

@@ -31,8 +31,7 @@ EmailTemplates["Infrastructure.Email.Templates"]
end end
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
UsersSlice -. sends email commands .-> SharedApp["Shared.Application<br>(email command contract)"] EmailsSlice == project ref, handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification ==> UsersSlice
EmailsSlice -. handles email commands .-> SharedApp
BrewerySlice --> FOUNDATION BrewerySlice --> FOUNDATION
LocationsSlice --> FOUNDATION LocationsSlice --> FOUNDATION
BeersSlice --> FOUNDATION BeersSlice --> FOUNDATION

View File

@@ -1,24 +0,0 @@
using Features.Emails.Commands.SendRegistrationEmail;
using Features.Emails.Services;
using Moq;
using Shared.Application.Emails;
namespace Features.Emails.Tests.Commands;
public class SendRegistrationEmailHandlerTests
{
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
Mock<IEmailDispatcher> dispatcherMock = new();
SendRegistrationEmailHandler handler = new(dispatcherMock.Object);
SendRegistrationEmailCommand command = new("Aaron", "aaron@example.com", "token-123");
await handler.Handle(command, CancellationToken.None);
dispatcherMock.Verify(
d => d.SendRegistrationEmailAsync("Aaron", "aaron@example.com", "token-123"),
Times.Once
);
}
}

View File

@@ -1,24 +0,0 @@
using Features.Emails.Commands.SendResendConfirmationEmail;
using Features.Emails.Services;
using Moq;
using Shared.Application.Emails;
namespace Features.Emails.Tests.Commands;
public class SendResendConfirmationEmailHandlerTests
{
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
Mock<IEmailDispatcher> dispatcherMock = new();
SendResendConfirmationEmailHandler handler = new(dispatcherMock.Object);
SendResendConfirmationEmailCommand command = new("Aaron", "aaron@example.com", "token-456");
await handler.Handle(command, CancellationToken.None);
dispatcherMock.Verify(
d => d.SendResendConfirmationEmailAsync("Aaron", "aaron@example.com", "token-456"),
Times.Once
);
}
}

View File

@@ -0,0 +1,63 @@
using Features.Auth.Notifications;
using Features.Emails.Notifications;
using Features.Emails.Services;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
namespace Features.Emails.Tests.Notifications;
public class ConfirmationEmailResendRequestedNotificationHandlerTests
{
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
Mock<IEmailDispatcher> dispatcherMock = new();
ConfirmationEmailResendRequestedNotificationHandler handler = new(
dispatcherMock.Object,
Mock.Of<ILogger<ConfirmationEmailResendRequestedNotificationHandler>>()
);
ConfirmationEmailResendRequestedNotification notification = new(
Guid.NewGuid(),
"Aaron",
"aaron@example.com",
"token-456"
);
await handler.Handle(notification, CancellationToken.None);
dispatcherMock.Verify(
d => d.SendResendConfirmationEmailAsync("Aaron", "aaron@example.com", "token-456"),
Times.Once
);
}
[Fact]
public async Task Handle_SwallowsDispatcherFailure()
{
Mock<IEmailDispatcher> dispatcherMock = new();
dispatcherMock
.Setup(d =>
d.SendResendConfirmationEmailAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()
)
)
.ThrowsAsync(new Exception("smtp down"));
ConfirmationEmailResendRequestedNotificationHandler handler = new(
dispatcherMock.Object,
Mock.Of<ILogger<ConfirmationEmailResendRequestedNotificationHandler>>()
);
ConfirmationEmailResendRequestedNotification notification = new(
Guid.NewGuid(),
"Aaron",
"aaron@example.com",
"token-456"
);
Func<Task> act = async () => await handler.Handle(notification, CancellationToken.None);
await act.Should().NotThrowAsync();
}
}

View File

@@ -0,0 +1,63 @@
using Features.Auth.Notifications;
using Features.Emails.Notifications;
using Features.Emails.Services;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
namespace Features.Emails.Tests.Notifications;
public class UserRegisteredNotificationHandlerTests
{
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
Mock<IEmailDispatcher> dispatcherMock = new();
UserRegisteredNotificationHandler handler = new(
dispatcherMock.Object,
Mock.Of<ILogger<UserRegisteredNotificationHandler>>()
);
UserRegisteredNotification notification = new(
Guid.NewGuid(),
"Aaron",
"aaron@example.com",
"token-123"
);
await handler.Handle(notification, CancellationToken.None);
dispatcherMock.Verify(
d => d.SendRegistrationEmailAsync("Aaron", "aaron@example.com", "token-123"),
Times.Once
);
}
[Fact]
public async Task Handle_SwallowsDispatcherFailure()
{
Mock<IEmailDispatcher> dispatcherMock = new();
dispatcherMock
.Setup(d =>
d.SendRegistrationEmailAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()
)
)
.ThrowsAsync(new Exception("smtp down"));
UserRegisteredNotificationHandler handler = new(
dispatcherMock.Object,
Mock.Of<ILogger<UserRegisteredNotificationHandler>>()
);
UserRegisteredNotification notification = new(
Guid.NewGuid(),
"Aaron",
"aaron@example.com",
"token-123"
);
Func<Task> act = async () => await handler.Handle(notification, CancellationToken.None);
await act.Should().NotThrowAsync();
}
}

View File

@@ -1,22 +0,0 @@
using Features.Emails.Services;
using MediatR;
using Shared.Application.Emails;
namespace Features.Emails.Commands.SendRegistrationEmail;
/// <summary>
/// Handles <see cref="SendRegistrationEmailCommand" />, the cross-slice command sent by Features.Users
/// after a new user registers.
/// </summary>
public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
: IRequestHandler<SendRegistrationEmailCommand>
{
public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken)
{
return emailDispatcher.SendRegistrationEmailAsync(
request.FirstName,
request.Email,
request.ConfirmationToken
);
}
}

View File

@@ -1,25 +0,0 @@
using Features.Emails.Services;
using MediatR;
using Shared.Application.Emails;
namespace Features.Emails.Commands.SendResendConfirmationEmail;
/// <summary>
/// Handles <see cref="SendResendConfirmationEmailCommand" />, the cross-slice command sent by Features.Users
/// when a user requests a fresh confirmation link.
/// </summary>
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
: IRequestHandler<SendResendConfirmationEmailCommand>
{
public Task Handle(
SendResendConfirmationEmailCommand request,
CancellationToken cancellationToken
)
{
return emailDispatcher.SendResendConfirmationEmailAsync(
request.FirstName,
request.Email,
request.ConfirmationToken
);
}
}

View File

@@ -18,5 +18,6 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
<ProjectReference Include="..\Features.Users\Features.Users.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,44 @@
using Features.Auth.Notifications;
using Features.Emails.Services;
using MediatR;
using Microsoft.Extensions.Logging;
namespace Features.Emails.Notifications;
/// <summary>
/// Handles <see cref="ConfirmationEmailResendRequestedNotification" />, the cross-slice notification
/// published by Features.Users when a user requests a fresh confirmation link, by sending the resend
/// confirmation email.
/// </summary>
public class ConfirmationEmailResendRequestedNotificationHandler(
IEmailDispatcher emailDispatcher,
ILogger<ConfirmationEmailResendRequestedNotificationHandler> logger
) : INotificationHandler<ConfirmationEmailResendRequestedNotification>
{
/// <remarks>
/// Delivery failures are logged and swallowed rather than rethrown, so that a broken mail server
/// never fails the resend request that already succeeded.
/// </remarks>
public async Task Handle(
ConfirmationEmailResendRequestedNotification notification,
CancellationToken cancellationToken
)
{
try
{
await emailDispatcher.SendResendConfirmationEmailAsync(
notification.FirstName,
notification.Email,
notification.ConfirmationToken
);
}
catch (Exception ex)
{
logger.LogError(
ex,
"Failed to send resend confirmation email to user {UserId}",
notification.UserId
);
}
}
}

View File

@@ -0,0 +1,40 @@
using Features.Auth.Notifications;
using Features.Emails.Services;
using MediatR;
using Microsoft.Extensions.Logging;
namespace Features.Emails.Notifications;
/// <summary>
/// Handles <see cref="UserRegisteredNotification" />, the cross-slice notification published by
/// Features.Users after a new user registers, by sending the registration confirmation email.
/// </summary>
public class UserRegisteredNotificationHandler(
IEmailDispatcher emailDispatcher,
ILogger<UserRegisteredNotificationHandler> logger
) : INotificationHandler<UserRegisteredNotification>
{
/// <remarks>
/// Delivery failures are logged and swallowed rather than rethrown, so that a broken mail server
/// never fails the registration that already succeeded.
/// </remarks>
public async Task Handle(UserRegisteredNotification notification, CancellationToken cancellationToken)
{
try
{
await emailDispatcher.SendRegistrationEmailAsync(
notification.FirstName,
notification.Email,
notification.ConfirmationToken
);
}
catch (Exception ex)
{
logger.LogError(
ex,
"Failed to send registration confirmation email to user {UserId}",
notification.UserId
);
}
}
}

View File

@@ -2,13 +2,13 @@ using Domain.Exceptions;
using Features.Auth.Commands.Authentication.RegisterUser; using Features.Auth.Commands.Authentication.RegisterUser;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Notifications;
using Features.Auth.Services; using Features.Auth.Services;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Moq; using Moq;
using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands; namespace Features.Auth.Tests.Commands;
@@ -86,10 +86,18 @@ public class RegisterUserHandlerTests
result.Username.Should().Be(command.Username); result.Username.Should().Be(command.Username);
result.AccessToken.Should().Be("access-token"); result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token"); result.RefreshToken.Should().Be("refresh-token");
result.ConfirmationEmailSent.Should().BeTrue();
_mediatorMock.Verify( _mediatorMock.Verify(
x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()), x =>
x.Publish(
It.Is<UserRegisteredNotification>(n =>
n.UserId == expectedUserId
&& n.FirstName == command.FirstName
&& n.Email == command.Email
&& n.ConfirmationToken == "confirmation-token"
),
It.IsAny<CancellationToken>()
),
Times.Once Times.Once
); );
} }
@@ -177,31 +185,4 @@ public class RegisterUserHandlerTests
); );
} }
[Fact]
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
{
RegisterUserCommand command = ValidCommand();
_userManagerMock
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
.ReturnsAsync(IdentityResult.Success);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("refresh-token");
_mediatorMock
.Setup(x =>
x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>())
)
.ThrowsAsync(new Exception("smtp down"));
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.ConfirmationEmailSent.Should().BeFalse();
result.AccessToken.Should().Be("access-token");
}
} }

View File

@@ -1,11 +1,11 @@
using Features.Auth.Commands.Authentication.ResendConfirmationEmail; using Features.Auth.Commands.Authentication.ResendConfirmationEmail;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Notifications;
using Features.Auth.Services; using Features.Auth.Services;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Moq; using Moq;
using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands; namespace Features.Auth.Tests.Commands;
@@ -47,11 +47,12 @@ public class ResendConfirmationEmailHandlerTests
_mediatorMock.Verify( _mediatorMock.Verify(
x => x =>
x.Send( x.Publish(
It.Is<SendResendConfirmationEmailCommand>(c => It.Is<ConfirmationEmailResendRequestedNotification>(n =>
c.FirstName == "Aaron" n.UserId == userId
&& c.Email == "aaron@example.com" && n.FirstName == "Aaron"
&& c.ConfirmationToken == "fresh-token" && n.Email == "aaron@example.com"
&& n.ConfirmationToken == "fresh-token"
), ),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
), ),
@@ -71,8 +72,8 @@ public class ResendConfirmationEmailHandlerTests
_mediatorMock.Verify( _mediatorMock.Verify(
x => x =>
x.Send( x.Publish(
It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<ConfirmationEmailResendRequestedNotification>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
), ),
Times.Never Times.Never
@@ -92,8 +93,8 @@ public class ResendConfirmationEmailHandlerTests
_mediatorMock.Verify( _mediatorMock.Verify(
x => x =>
x.Send( x.Publish(
It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<ConfirmationEmailResendRequestedNotification>(),
It.IsAny<CancellationToken>() It.IsAny<CancellationToken>()
), ),
Times.Never Times.Never

View File

@@ -3,17 +3,17 @@ using Domain.Exceptions;
using Features.Auth.Commands.Profile.CreateUserProfile; using Features.Auth.Commands.Profile.CreateUserProfile;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Notifications;
using Features.Auth.Services; using Features.Auth.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails;
namespace Features.Auth.Commands.Authentication.RegisterUser; namespace Features.Auth.Commands.Authentication.RegisterUser;
/// <summary> /// <summary>
/// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" /> /// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
/// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and /// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and
/// attempts to send the registration confirmation email via Features.Emails. /// publishes <see cref="UserRegisteredNotification" /> for Features.Emails to send the confirmation email.
/// </summary> /// </summary>
public class RegisterUserHandler( public class RegisterUserHandler(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
@@ -60,31 +60,19 @@ public class RegisterUserHandler(
user.Id, user.Id,
user.UserName, user.UserName,
string.Empty, string.Empty,
string.Empty, string.Empty
false
); );
bool emailSent = false; await mediator.Publish(
try new UserRegisteredNotification(user.Id, request.FirstName, user.Email, confirmationToken),
{ cancellationToken
await mediator.Send( );
new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken
);
emailSent = true;
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync(ex.Message);
Console.WriteLine("Could not send email.");
}
return new RegistrationPayload( return new RegistrationPayload(
user.Id, user.Id,
user.UserName, user.UserName,
refreshToken, refreshToken,
accessToken, accessToken
emailSent
); );
} }
} }

View File

@@ -1,14 +1,14 @@
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Notifications;
using Features.Auth.Services; using Features.Auth.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails;
namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail; namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail;
/// <summary> /// <summary>
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and /// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails. /// publishing <see cref="ConfirmationEmailResendRequestedNotification" /> for Features.Emails to send it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration) /// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
@@ -33,8 +33,13 @@ public class ResendConfirmationEmailHandler(
return; // Already confirmed, no-op return; // Already confirmed, no-op
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName); string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
await mediator.Send( await mediator.Publish(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken), new ConfirmationEmailResendRequestedNotification(
user.Id,
user.FirstName,
user.Email,
confirmationToken
),
cancellationToken cancellationToken
); );
} }

View File

@@ -13,9 +13,7 @@ public record RegistrationPayload(
Guid UserAccountId, Guid UserAccountId,
string Username, string Username,
string RefreshToken, string RefreshToken,
string AccessToken, string AccessToken);
bool ConfirmationEmailSent
);
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary> /// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace Features.Auth.Notifications;
/// <summary>
/// Published after a user requests a fresh confirmation link. Handled by Features.Emails to send the
/// resend confirmation email.
/// </summary>
public record ConfirmationEmailResendRequestedNotification(
Guid UserId,
string FirstName,
string Email,
string ConfirmationToken
) : INotification;

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace Features.Auth.Notifications;
/// <summary>
/// Published after a new user registers. Handled by Features.Emails to send the registration
/// confirmation email.
/// </summary>
public record UserRegisteredNotification(
Guid UserId,
string FirstName,
string Email,
string ConfirmationToken
) : INotification;

View File

@@ -1,10 +0,0 @@
using MediatR;
namespace Shared.Application.Emails;
/// <summary>
/// Cross-slice command sent by Features.Users and handled by Features.Emails to dispatch a
/// registration confirmation email, without either slice taking a project reference on the other.
/// </summary>
public record SendRegistrationEmailCommand(string FirstName, string Email, string ConfirmationToken)
: IRequest;

View File

@@ -1,13 +0,0 @@
using MediatR;
namespace Shared.Application.Emails;
/// <summary>
/// Cross-slice command sent by Features.Users and handled by Features.Emails to dispatch a
/// fresh confirmation-link email, without either slice taking a project reference on the other.
/// </summary>
public record SendResendConfirmationEmailCommand(
string FirstName,
string Email,
string ConfirmationToken
) : IRequest;

View File

@@ -21,9 +21,7 @@ interface LoginPayload {
accessToken: string; accessToken: string;
} }
interface RegistrationPayload extends LoginPayload { type RegistrationPayload = LoginPayload;
confirmationEmailSent: boolean;
}
interface UpdateUsernamePayload { interface UpdateUsernamePayload {
userAccountId: string; userAccountId: string;