Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "https://localhost:7001;http://localhost:5001"
"ASPNETCORE_URLS": "http://localhost:10000"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@
namespace Application.EventHandlers;

/// <summary>
/// Handler for sending welcome email when user is created
/// Handler for sending welcome email when user verifies their email
/// </summary>
public sealed class UserCreatedSendEmailEventHandler(
public sealed class EmailVerifiedEventHandler(
IEmailService emailService,
ILogger<UserCreatedSendEmailEventHandler> logger)
: IDomainEventHandler<UserCreatedEvent>
ILogger<EmailVerifiedEventHandler> logger)
: IDomainEventHandler<EmailVerifiedEvent>
{
public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
public async Task Handle(EmailVerifiedEvent notification, CancellationToken cancellationToken)
{
logger.LogInformation(
"Sending welcome email to user {UserId} at {Email}",
"Sending welcome email to verified user {UserId} at {Email}",
notification.UserId,
notification.Email);

Expand All @@ -36,7 +36,6 @@ await emailService.SendWelcomeEmailAsync(
logger.LogError(ex,
"Failed to send welcome email to {Email}",
notification.Email);
// Don't throw - we don't want email failures to break the registration flow
}
}
}
58 changes: 58 additions & 0 deletions src/Application/EventHandlers/UserCreatedEventHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Application.Common;
using Application.Interfaces;
using Domain.Events.User;
using Microsoft.Extensions.Logging;

namespace Application.EventHandlers;

/// <summary>
/// Handler for sending email verification when user is created
/// </summary>
public sealed class UserCreatedEventHandler(
IEmailService emailService,
ITokenGenerationService tokenService,
ILogger<UserCreatedEventHandler> logger)
: IDomainEventHandler<UserCreatedEvent>
{
public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
{
// If user registered with social provider (Google, etc.), email is already verified
// Skip sending verification email
if (notification.IsEmailVerified)
{
logger.LogInformation(
"User {UserId} registered with verified email (social provider). Skipping verification email.",
notification.UserId);
return;
}

logger.LogInformation(
"Sending email verification to user {UserId} at {Email}",
notification.UserId,
notification.Email);

try
{
// Generate email confirmation token using Identity
var token = await tokenService.GenerateEmailConfirmationTokenAsync(notification.UserId);
var verificationLink = $"http://localhost:10000/api/v1/auth/verify-email?userId={notification.UserId}&token={Uri.EscapeDataString(token)}";

await emailService.SendEmailVerificationAsync(
notification.Email,
notification.FullName,
verificationLink,
cancellationToken);

logger.LogInformation(
"Verification email sent successfully to {Email}",
notification.Email);
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to send verification email to {Email}",
notification.Email);
// Don't throw - we don't want email failures to break the registration flow
}
}
}
13 changes: 13 additions & 0 deletions src/Application/Features/Auth/VerifyEmail/VerifyEmailCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Application.Common;
using Domain.Common;

namespace Application.Features.Auth.VerifyEmail;

/// <summary>
/// Command to verify user's email address
/// </summary>
public sealed record VerifyEmailCommand : ICommand<Result>
{
public required Guid UserId { get; init; }
public required string Token { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Application.Common;
using Application.Interfaces;
using Domain.Common;
using Domain.Events.User;
using Microsoft.Extensions.Logging;

namespace Application.Features.Auth.VerifyEmail;

/// <summary>
/// Handler for verifying user's email address
/// </summary>
public sealed class VerifyEmailCommandHandler(
IUserRepository userRepository,
ITokenGenerationService tokenService,
ILogger<VerifyEmailCommandHandler> logger)
: ICommandHandler<VerifyEmailCommand, Result>
{
public async Task<Result> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
{
// Find user by ID
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
logger.LogWarning("User not found: {UserId}", request.UserId);
return Result.Failure(Error.NotFound("User.NotFound", "User not found"));
}

// Check if email is already verified
var isAlreadyVerified = await userRepository.IsEmailVerifiedAsync(request.UserId, cancellationToken);
if (isAlreadyVerified)
{
logger.LogInformation("Email already confirmed for user {UserId}", request.UserId);
return Result.Success();
}

// Validate token using Identity's built-in token provider
// Token is stored in AspNetUserTokens table and validated automatically
var verifySuccess = await tokenService.ConfirmEmailAsync(request.UserId, request.Token);
if (!verifySuccess)
{
logger.LogError("Invalid or expired token for user {UserId}", request.UserId);
return Result.Failure(Error.Failure("User.InvalidToken", "Invalid or expired verification token"));
}

// Raise EmailVerifiedEvent to send welcome email
user.AddDomainEvent(new EmailVerifiedEvent
{
UserId = user.Id,
Email = user.Email,
FullName = user.FullName
});

await userRepository.UpdateAsync(user, cancellationToken);

logger.LogInformation("Email verified successfully for user {UserId}", request.UserId);
return Result.Success();
}
}
11 changes: 10 additions & 1 deletion src/Application/Interfaces/IEmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ namespace Application.Interfaces;
public interface IEmailService
{
/// <summary>
/// Send welcome email to new user
/// Send email verification to new user
/// </summary>
/// <param name="email">User's email address</param>
/// <param name="fullName">User's full name</param>
/// <param name="verificationLink">Email verification link</param>
/// <param name="cancellationToken">Cancellation token</param>
Task SendEmailVerificationAsync(string email, string fullName, string verificationLink, CancellationToken cancellationToken = default);

/// <summary>
/// Send welcome email to verified user
/// </summary>
/// <param name="email">User's email address</param>
/// <param name="fullName">User's full name</param>
Expand Down
22 changes: 22 additions & 0 deletions src/Application/Interfaces/IEmailTemplateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Application.Interfaces;

/// <summary>
/// Service for generating email templates
/// </summary>
public interface IEmailTemplateService
{
/// <summary>
/// Generate welcome email template
/// </summary>
string GetWelcomeEmailTemplate(string fullName);

/// <summary>
/// Generate password reset email template
/// </summary>
string GetPasswordResetEmailTemplate(string fullName, string resetLink);

/// <summary>
/// Generate email verification template
/// </summary>
string GetEmailVerificationTemplate(string fullName, string verificationLink);
}
17 changes: 17 additions & 0 deletions src/Application/Interfaces/ITokenGenerationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Application.Interfaces;

/// <summary>
/// Service for generating verification tokens
/// </summary>
public interface ITokenGenerationService
{
/// <summary>
/// Generate email confirmation token for user
/// </summary>
Task<string> GenerateEmailConfirmationTokenAsync(Guid userId);

/// <summary>
/// Confirm email with token
/// </summary>
Task<bool> ConfirmEmailAsync(Guid userId, string token);
}
10 changes: 10 additions & 0 deletions src/Application/Interfaces/IUserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,14 @@ public interface IUserRepository
/// Create user
/// </summary>
Task<User> CreateAsync(User user, CancellationToken cancellationToken = default);

/// <summary>
/// Verify user's email
/// </summary>
Task<bool> VerifyEmailAsync(Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Check if user's email is verified
/// </summary>
Task<bool> IsEmailVerifiedAsync(Guid userId, CancellationToken cancellationToken = default);
}
14 changes: 14 additions & 0 deletions src/Domain/Events/User/EmailVerifiedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Domain.Common;

namespace Domain.Events.User;

/// <summary>
/// Event raised when user verifies their email
/// </summary>
public sealed record EmailVerifiedEvent : IDomainEvent
{
public required Guid UserId { get; init; }
public required string Email { get; init; }
public required string FullName { get; init; }
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
}
4 changes: 3 additions & 1 deletion src/Domain/Events/User/UserCreatedEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ public sealed record UserCreatedEvent : IDomainEvent
public string Email { get; }
public string FullName { get; }
public List<string> Roles { get; }
public bool IsEmailVerified { get; }
public DateTime OccurredOn { get; }

public UserCreatedEvent(Guid userId, string email, string fullName, List<string> roles)
public UserCreatedEvent(Guid userId, string email, string fullName, List<string> roles, bool isEmailVerified = false)
{
UserId = userId;
Email = email;
FullName = fullName;
Roles = roles;
IsEmailVerified = isEmailVerified;
OccurredOn = DateTime.UtcNow;
}
}
18 changes: 0 additions & 18 deletions src/Domain/Events/User/UserDeactivatedEvent.cs

This file was deleted.

22 changes: 0 additions & 22 deletions src/Domain/Events/User/UserUpdatedEvent.cs

This file was deleted.

5 changes: 4 additions & 1 deletion src/Infrastructure/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Infrastructure.Data.Contexts;
using Infrastructure.Repositories;
using Infrastructure.Services;
using Infrastructure.Services.Email;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -96,9 +97,11 @@ private static IServiceCollection AddInfrastructureApplicationServices(this ISer
// Authentication & Security
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ITokenGenerationService, TokenGenerationService>();

// Email Service
// Email Services
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IEmailTemplateService, EmailTemplateService>();

// Domain Events
services.AddScoped<IDomainEventDispatcher, DomainEventDispatcher>();
Expand Down
9 changes: 9 additions & 0 deletions src/Infrastructure/Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,13 @@
<ProjectReference Include="..\Application\Application.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="Templates\Email\*.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Templates\Email\Assets\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
Loading