From d9589332506493620b1f1fd7f15706abbc92447c Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 5 Sep 2026 22:37:00 -0400 Subject: [PATCH] Replace shared email commands with MediatR notifications (#280) --- docs/web/architecture.md | 12 ++-- docs/web/diagrams/auth/registration.mmd | 14 ++--- .../web/diagrams/auth/resend-confirmation.mmd | 9 ++- docs/web/diagrams/vertical-slice.md | 34 +++++----- .../vertical-slice/project-references.mmd | 3 +- .../SendRegistrationEmailHandlerTests.cs | 24 ------- ...SendResendConfirmationEmailHandlerTests.cs | 24 ------- ...ResendRequestedNotificationHandlerTests.cs | 63 +++++++++++++++++++ .../UserRegisteredNotificationHandlerTests.cs | 63 +++++++++++++++++++ .../SendRegistrationEmailHandler.cs | 22 ------- .../SendResendConfirmationEmailHandler.cs | 25 -------- .../Features.Emails/Features.Emails.csproj | 1 + ...EmailResendRequestedNotificationHandler.cs | 44 +++++++++++++ .../UserRegisteredNotificationHandler.cs | 40 ++++++++++++ .../Commands/RegisterUserHandlerTests.cs | 41 ++++-------- .../ResendConfirmationEmailHandlerTests.cs | 21 ++++--- .../RegisterUser/RegisterUserHandler.cs | 28 +++------ .../ResendConfirmationEmailHandler.cs | 13 ++-- .../Features.Users/Dtos/AuthPayloads.cs | 4 +- ...rmationEmailResendRequestedNotification.cs | 14 +++++ .../UserRegisteredNotification.cs | 14 +++++ .../Emails/SendRegistrationEmailCommand.cs | 10 --- .../SendResendConfirmationEmailCommand.cs | 13 ---- web/frontend/app/features/auth/auth.server.ts | 4 +- 24 files changed, 322 insertions(+), 218 deletions(-) delete mode 100644 web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs delete mode 100644 web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs create mode 100644 web/backend/Features/Features.Emails.Tests/Notifications/ConfirmationEmailResendRequestedNotificationHandlerTests.cs create mode 100644 web/backend/Features/Features.Emails.Tests/Notifications/UserRegisteredNotificationHandlerTests.cs delete mode 100644 web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs delete mode 100644 web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs create mode 100644 web/backend/Features/Features.Emails/Notifications/ConfirmationEmailResendRequestedNotificationHandler.cs create mode 100644 web/backend/Features/Features.Emails/Notifications/UserRegisteredNotificationHandler.cs create mode 100644 web/backend/Features/Features.Users/Notifications/ConfirmationEmailResendRequestedNotification.cs create mode 100644 web/backend/Features/Features.Users/Notifications/UserRegisteredNotification.cs delete mode 100644 web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs delete mode 100644 web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 07d465df..e11f7c1b 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -77,7 +77,7 @@ flowchart TB subgraph SHARED["Shared Projects"] direction LR SH1["Shared.Contracts
ResponseBody envelope"] - SH2["Shared.Application
ValidationBehavior,
cross-slice email commands"] + SH2["Shared.Application
ValidationBehavior"] end subgraph DOMAIN["Domain Layer"] @@ -98,7 +98,7 @@ flowchart TB DB[("Database (SQL Server)")] HOST -- "discovers controllers via
AddApplicationPart" --> SLICES - S1 -- "SendRegistrationEmailCommand
(via Shared.Application contract)" --> S3 + S3 == "ProjectReference
(handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification)" ==> S1 S1 -- "ProjectReference
(avatar upload)" --> S5 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 PhotoUpload) - `Shared.Contracts`, `Shared.Application` -- No other `Features.*` project, with one exception: `Features.Users` references - `Features.PhotoUpload` directly for avatar uploads +- No other `Features.*` project, with two exceptions: `Features.Users` + references `Features.PhotoUpload` directly for avatar uploads, and + `Features.Emails` references `Features.Users` directly to handle + `UserRegisteredNotification` and `ConfirmationEmailResendRequestedNotification` **Rules**: @@ -191,7 +193,7 @@ needs it, or because duplicating it four times would be worse than sharing it | Project | Purpose | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Shared.Contracts` | `ResponseBody`/`ResponseBody`, the API response envelope every controller returns | -| `Shared.Application` | `ValidationBehavior` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) | +| `Shared.Application` | `ValidationBehavior`, the MediatR pipeline behavior that runs FluentValidation before a handler executes | **Rules**: diff --git a/docs/web/diagrams/auth/registration.mmd b/docs/web/diagrams/auth/registration.mmd index 9403f83c..83b34edf 100644 --- a/docs/web/diagrams/auth/registration.mmd +++ b/docs/web/diagrams/auth/registration.mmd @@ -127,9 +127,9 @@ sequenceDiagram TokenSvc-->>RegHandler: Confirmation Token deactivate TokenSvc - RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract) + RegHandler->>Mediator: Publish(UserRegisteredNotification) 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) activate EmailDispatcher @@ -147,15 +147,15 @@ sequenceDiagram SMTP-->>EmailDispatcher: Success / throws deactivate SMTP deactivate EmailDispatcher - deactivate Mediator alt Email sent successfully - RegHandler->>RegHandler: emailSent = true - else mediator.Send throws - RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false + Note right of Mediator: UserRegisteredNotificationHandler completes normally + else EmailDispatcher throws + Note right of Mediator: UserRegisteredNotificationHandler catches and logs the exception — registration is unaffected end + deactivate Mediator - RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent) + RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken) deactivate RegHandler Mediator-->>API: RegistrationPayload deactivate Mediator diff --git a/docs/web/diagrams/auth/resend-confirmation.mmd b/docs/web/diagrams/auth/resend-confirmation.mmd index cd985da3..514556fb 100644 --- a/docs/web/diagrams/auth/resend-confirmation.mmd +++ b/docs/web/diagrams/auth/resend-confirmation.mmd @@ -51,13 +51,20 @@ sequenceDiagram TokenSvc-->>ResendHandler: confirmation token deactivate TokenSvc - ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand) + ResendHandler->>Mediator: Publish(ConfirmationEmailResendRequestedNotification) 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) activate EmailDispatcher EmailDispatcher->>Template: Render email (same Razor pipeline as registration) EmailDispatcher->>SMTP: SendAsync(email, subject, body) 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 end end diff --git a/docs/web/diagrams/vertical-slice.md b/docs/web/diagrams/vertical-slice.md index 69d4dacb..5d7467c3 100644 --- a/docs/web/diagrams/vertical-slice.md +++ b/docs/web/diagrams/vertical-slice.md @@ -16,13 +16,14 @@ doesn't wire up any slice; it applies schema directly. ## Project reference dependencies -Solid arrows are compile-time project references. Dashed arrows are the two -places slices talk to each other without a direct reference: the email MediatR -contract routed through `Shared.Application`, and the one place that pattern is -broken outright. Every DB-backed slice depends on the same three foundation -projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn -as one box instead of fifteen separate arrows; see the notes for what else each -of those foundation projects pulls in. +Solid arrows are compile-time project references — every slice-to-slice +dependency is one now that the email MediatR contract has moved off +`Shared.Application`: `Features.Users` on `Features.PhotoUpload` for avatar +uploads, and `Features.Emails` on `Features.Users` for the registration and +resend-confirmation notifications. Every DB-backed slice depends on the same +three foundation projects (`Shared.Contracts`, `Domain.Entities`, +`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 --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 yet) -- **The one exception to slice isolation**: `Features.Users` takes a direct - project reference to `Features.PhotoUpload` - - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` - - unlike the email flow, the command type lives in the target slice itself, - not in `Shared.Application` +- **The exceptions to slice isolation**: + - `Features.Users` takes a direct project reference to `Features.PhotoUpload` + - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` + - the command type lives in the target slice itself, not in + `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**: - `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 behavior alone diff --git a/docs/web/diagrams/vertical-slice/project-references.mmd b/docs/web/diagrams/vertical-slice/project-references.mmd index 7953bfaa..5ea0a881 100644 --- a/docs/web/diagrams/vertical-slice/project-references.mmd +++ b/docs/web/diagrams/vertical-slice/project-references.mmd @@ -31,8 +31,7 @@ EmailTemplates["Infrastructure.Email.Templates"] end UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice -UsersSlice -. sends email commands .-> SharedApp["Shared.Application
(email command contract)"] -EmailsSlice -. handles email commands .-> SharedApp +EmailsSlice == project ref, handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification ==> UsersSlice BrewerySlice --> FOUNDATION LocationsSlice --> FOUNDATION BeersSlice --> FOUNDATION diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs deleted file mode 100644 index 9b338b9a..00000000 --- a/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs +++ /dev/null @@ -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 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 - ); - } -} diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs deleted file mode 100644 index b8b02f65..00000000 --- a/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs +++ /dev/null @@ -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 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 - ); - } -} diff --git a/web/backend/Features/Features.Emails.Tests/Notifications/ConfirmationEmailResendRequestedNotificationHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Notifications/ConfirmationEmailResendRequestedNotificationHandlerTests.cs new file mode 100644 index 00000000..c0f7e60a --- /dev/null +++ b/web/backend/Features/Features.Emails.Tests/Notifications/ConfirmationEmailResendRequestedNotificationHandlerTests.cs @@ -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 dispatcherMock = new(); + ConfirmationEmailResendRequestedNotificationHandler handler = new( + dispatcherMock.Object, + Mock.Of>() + ); + 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 dispatcherMock = new(); + dispatcherMock + .Setup(d => + d.SendResendConfirmationEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ThrowsAsync(new Exception("smtp down")); + ConfirmationEmailResendRequestedNotificationHandler handler = new( + dispatcherMock.Object, + Mock.Of>() + ); + ConfirmationEmailResendRequestedNotification notification = new( + Guid.NewGuid(), + "Aaron", + "aaron@example.com", + "token-456" + ); + + Func act = async () => await handler.Handle(notification, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Emails.Tests/Notifications/UserRegisteredNotificationHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Notifications/UserRegisteredNotificationHandlerTests.cs new file mode 100644 index 00000000..a2bcf4eb --- /dev/null +++ b/web/backend/Features/Features.Emails.Tests/Notifications/UserRegisteredNotificationHandlerTests.cs @@ -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 dispatcherMock = new(); + UserRegisteredNotificationHandler handler = new( + dispatcherMock.Object, + Mock.Of>() + ); + 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 dispatcherMock = new(); + dispatcherMock + .Setup(d => + d.SendRegistrationEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ThrowsAsync(new Exception("smtp down")); + UserRegisteredNotificationHandler handler = new( + dispatcherMock.Object, + Mock.Of>() + ); + UserRegisteredNotification notification = new( + Guid.NewGuid(), + "Aaron", + "aaron@example.com", + "token-123" + ); + + Func act = async () => await handler.Handle(notification, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs deleted file mode 100644 index 6bd22d78..00000000 --- a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Features.Emails.Services; -using MediatR; -using Shared.Application.Emails; - -namespace Features.Emails.Commands.SendRegistrationEmail; - -/// -/// Handles , the cross-slice command sent by Features.Users -/// after a new user registers. -/// -public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher) - : IRequestHandler -{ - public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken) - { - return emailDispatcher.SendRegistrationEmailAsync( - request.FirstName, - request.Email, - request.ConfirmationToken - ); - } -} diff --git a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs deleted file mode 100644 index c37551e0..00000000 --- a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Features.Emails.Services; -using MediatR; -using Shared.Application.Emails; - -namespace Features.Emails.Commands.SendResendConfirmationEmail; - -/// -/// Handles , the cross-slice command sent by Features.Users -/// when a user requests a fresh confirmation link. -/// -public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher) - : IRequestHandler -{ - public Task Handle( - SendResendConfirmationEmailCommand request, - CancellationToken cancellationToken - ) - { - return emailDispatcher.SendResendConfirmationEmailAsync( - request.FirstName, - request.Email, - request.ConfirmationToken - ); - } -} diff --git a/web/backend/Features/Features.Emails/Features.Emails.csproj b/web/backend/Features/Features.Emails/Features.Emails.csproj index 713ce39d..3f4e1875 100644 --- a/web/backend/Features/Features.Emails/Features.Emails.csproj +++ b/web/backend/Features/Features.Emails/Features.Emails.csproj @@ -18,5 +18,6 @@ + diff --git a/web/backend/Features/Features.Emails/Notifications/ConfirmationEmailResendRequestedNotificationHandler.cs b/web/backend/Features/Features.Emails/Notifications/ConfirmationEmailResendRequestedNotificationHandler.cs new file mode 100644 index 00000000..fd6b9422 --- /dev/null +++ b/web/backend/Features/Features.Emails/Notifications/ConfirmationEmailResendRequestedNotificationHandler.cs @@ -0,0 +1,44 @@ +using Features.Auth.Notifications; +using Features.Emails.Services; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Features.Emails.Notifications; + +/// +/// Handles , the cross-slice notification +/// published by Features.Users when a user requests a fresh confirmation link, by sending the resend +/// confirmation email. +/// +public class ConfirmationEmailResendRequestedNotificationHandler( + IEmailDispatcher emailDispatcher, + ILogger logger +) : INotificationHandler +{ + /// + /// Delivery failures are logged and swallowed rather than rethrown, so that a broken mail server + /// never fails the resend request that already succeeded. + /// + 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 + ); + } + } +} diff --git a/web/backend/Features/Features.Emails/Notifications/UserRegisteredNotificationHandler.cs b/web/backend/Features/Features.Emails/Notifications/UserRegisteredNotificationHandler.cs new file mode 100644 index 00000000..41e1d893 --- /dev/null +++ b/web/backend/Features/Features.Emails/Notifications/UserRegisteredNotificationHandler.cs @@ -0,0 +1,40 @@ +using Features.Auth.Notifications; +using Features.Emails.Services; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Features.Emails.Notifications; + +/// +/// Handles , the cross-slice notification published by +/// Features.Users after a new user registers, by sending the registration confirmation email. +/// +public class UserRegisteredNotificationHandler( + IEmailDispatcher emailDispatcher, + ILogger logger +) : INotificationHandler +{ + /// + /// Delivery failures are logged and swallowed rather than rethrown, so that a broken mail server + /// never fails the registration that already succeeded. + /// + 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 + ); + } + } +} diff --git a/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs index d2ee91bf..68543336 100644 --- a/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs @@ -2,13 +2,13 @@ using Domain.Exceptions; using Features.Auth.Commands.Authentication.RegisterUser; using Features.Auth.Dtos; using Features.Auth.Identity; +using Features.Auth.Notifications; using Features.Auth.Services; using Features.Auth.Tests.TestSupport; using FluentAssertions; using MediatR; using Microsoft.AspNetCore.Identity; using Moq; -using Shared.Application.Emails; namespace Features.Auth.Tests.Commands; @@ -86,10 +86,18 @@ public class RegisterUserHandlerTests result.Username.Should().Be(command.Username); result.AccessToken.Should().Be("access-token"); result.RefreshToken.Should().Be("refresh-token"); - result.ConfirmationEmailSent.Should().BeTrue(); _mediatorMock.Verify( - x => x.Send(It.IsAny(), It.IsAny()), + x => + x.Publish( + It.Is(n => + n.UserId == expectedUserId + && n.FirstName == command.FirstName + && n.Email == command.Email + && n.ConfirmationToken == "confirmation-token" + ), + It.IsAny() + ), 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(), command.Password)) - .Callback((u, _) => u.Id = Guid.NewGuid()) - .ReturnsAsync(IdentityResult.Success); - - _tokenServiceMock - .Setup(x => x.GenerateAccessToken(It.IsAny(), It.IsAny())) - .Returns("access-token"); - _tokenServiceMock - .Setup(x => x.GenerateRefreshToken(It.IsAny(), It.IsAny())) - .Returns("refresh-token"); - _mediatorMock - .Setup(x => - x.Send(It.IsAny(), It.IsAny()) - ) - .ThrowsAsync(new Exception("smtp down")); - - RegistrationPayload result = await _handler.Handle(command, CancellationToken.None); - - result.ConfirmationEmailSent.Should().BeFalse(); - result.AccessToken.Should().Be("access-token"); - } } diff --git a/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs index 974b94d9..6f460ab6 100644 --- a/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs @@ -1,11 +1,11 @@ using Features.Auth.Commands.Authentication.ResendConfirmationEmail; using Features.Auth.Identity; +using Features.Auth.Notifications; using Features.Auth.Services; using Features.Auth.Tests.TestSupport; using MediatR; using Microsoft.AspNetCore.Identity; using Moq; -using Shared.Application.Emails; namespace Features.Auth.Tests.Commands; @@ -47,11 +47,12 @@ public class ResendConfirmationEmailHandlerTests _mediatorMock.Verify( x => - x.Send( - It.Is(c => - c.FirstName == "Aaron" - && c.Email == "aaron@example.com" - && c.ConfirmationToken == "fresh-token" + x.Publish( + It.Is(n => + n.UserId == userId + && n.FirstName == "Aaron" + && n.Email == "aaron@example.com" + && n.ConfirmationToken == "fresh-token" ), It.IsAny() ), @@ -71,8 +72,8 @@ public class ResendConfirmationEmailHandlerTests _mediatorMock.Verify( x => - x.Send( - It.IsAny(), + x.Publish( + It.IsAny(), It.IsAny() ), Times.Never @@ -92,8 +93,8 @@ public class ResendConfirmationEmailHandlerTests _mediatorMock.Verify( x => - x.Send( - It.IsAny(), + x.Publish( + It.IsAny(), It.IsAny() ), Times.Never diff --git a/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs index 8d22714e..077b2223 100644 --- a/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs @@ -3,17 +3,17 @@ using Domain.Exceptions; using Features.Auth.Commands.Profile.CreateUserProfile; using Features.Auth.Dtos; using Features.Auth.Identity; +using Features.Auth.Notifications; using Features.Auth.Services; using MediatR; using Microsoft.AspNetCore.Identity; -using Shared.Application.Emails; namespace Features.Auth.Commands.Authentication.RegisterUser; /// /// Handles : creates the user via /// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and -/// attempts to send the registration confirmation email via Features.Emails. +/// publishes for Features.Emails to send the confirmation email. /// public class RegisterUserHandler( UserManager userManager, @@ -60,31 +60,19 @@ public class RegisterUserHandler( user.Id, user.UserName, string.Empty, - string.Empty, - false + string.Empty ); - bool emailSent = false; - try - { - 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."); - } + await mediator.Publish( + new UserRegisteredNotification(user.Id, request.FirstName, user.Email, confirmationToken), + cancellationToken + ); return new RegistrationPayload( user.Id, user.UserName, refreshToken, - accessToken, - emailSent + accessToken ); } } diff --git a/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs index eabc4a65..69edb439 100644 --- a/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs @@ -1,14 +1,14 @@ using Features.Auth.Identity; +using Features.Auth.Notifications; using Features.Auth.Services; using MediatR; using Microsoft.AspNetCore.Identity; -using Shared.Application.Emails; namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail; /// /// Handles by generating a fresh confirmation token and -/// sending it via Features.Emails. +/// publishing for Features.Emails to send it. /// /// /// 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 string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName); - await mediator.Send( - new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken), + await mediator.Publish( + new ConfirmationEmailResendRequestedNotification( + user.Id, + user.FirstName, + user.Email, + confirmationToken + ), cancellationToken ); } diff --git a/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs b/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs index f6c9e9ba..d63d3eaf 100644 --- a/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs +++ b/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs @@ -13,9 +13,7 @@ public record RegistrationPayload( Guid UserAccountId, string Username, string RefreshToken, - string AccessToken, - bool ConfirmationEmailSent -); + string AccessToken); /// Payload returned to the client after a user account's email has been confirmed. public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); diff --git a/web/backend/Features/Features.Users/Notifications/ConfirmationEmailResendRequestedNotification.cs b/web/backend/Features/Features.Users/Notifications/ConfirmationEmailResendRequestedNotification.cs new file mode 100644 index 00000000..ef731dd1 --- /dev/null +++ b/web/backend/Features/Features.Users/Notifications/ConfirmationEmailResendRequestedNotification.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace Features.Auth.Notifications; + +/// +/// Published after a user requests a fresh confirmation link. Handled by Features.Emails to send the +/// resend confirmation email. +/// +public record ConfirmationEmailResendRequestedNotification( + Guid UserId, + string FirstName, + string Email, + string ConfirmationToken +) : INotification; diff --git a/web/backend/Features/Features.Users/Notifications/UserRegisteredNotification.cs b/web/backend/Features/Features.Users/Notifications/UserRegisteredNotification.cs new file mode 100644 index 00000000..fb54185d --- /dev/null +++ b/web/backend/Features/Features.Users/Notifications/UserRegisteredNotification.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace Features.Auth.Notifications; + +/// +/// Published after a new user registers. Handled by Features.Emails to send the registration +/// confirmation email. +/// +public record UserRegisteredNotification( + Guid UserId, + string FirstName, + string Email, + string ConfirmationToken +) : INotification; diff --git a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs deleted file mode 100644 index 80e1133e..00000000 --- a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using MediatR; - -namespace Shared.Application.Emails; - -/// -/// 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. -/// -public record SendRegistrationEmailCommand(string FirstName, string Email, string ConfirmationToken) - : IRequest; diff --git a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs deleted file mode 100644 index af2fa75b..00000000 --- a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs +++ /dev/null @@ -1,13 +0,0 @@ -using MediatR; - -namespace Shared.Application.Emails; - -/// -/// 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. -/// -public record SendResendConfirmationEmailCommand( - string FirstName, - string Email, - string ConfirmationToken -) : IRequest; diff --git a/web/frontend/app/features/auth/auth.server.ts b/web/frontend/app/features/auth/auth.server.ts index e3835974..29abc33f 100644 --- a/web/frontend/app/features/auth/auth.server.ts +++ b/web/frontend/app/features/auth/auth.server.ts @@ -21,9 +21,7 @@ interface LoginPayload { accessToken: string; } -interface RegistrationPayload extends LoginPayload { - confirmationEmailSent: boolean; -} +type RegistrationPayload = LoginPayload; interface UpdateUsernamePayload { userAccountId: string;