mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Replace shared email commands with MediatR notifications (#280)
This commit is contained in:
@@ -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**:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user