Adds transactional outbox and email verification
Implements outbox pattern for reliable message delivery Adds email verification flow with Postmark integration Enhances account registration with secure token generation Introduces background processing for asynchronous email sending Implements database-level notification mechanism for message processing
This commit is contained in:
parent
88812177a9
commit
54b057886c
35 changed files with 1174 additions and 358 deletions
|
|
@ -0,0 +1,9 @@
|
|||
using PlanTempus.Core.CommandQueries;
|
||||
|
||||
namespace PlanTempus.Components.Accounts.ConfirmEmail;
|
||||
|
||||
public class ConfirmEmailCommand : Command
|
||||
{
|
||||
public required string Email { get; set; }
|
||||
public required string Token { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#nullable enable
|
||||
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.CommandQueries;
|
||||
using PlanTempus.Core.Database;
|
||||
|
||||
namespace PlanTempus.Components.Accounts.ConfirmEmail;
|
||||
|
||||
public class ConfirmEmailHandler(IDatabaseOperations databaseOperations) : ICommandHandler<ConfirmEmailCommand>
|
||||
{
|
||||
public async Task<CommandResponse> Handle(ConfirmEmailCommand command)
|
||||
{
|
||||
using var db = databaseOperations.CreateScope(nameof(ConfirmEmailHandler));
|
||||
|
||||
var sql = @"
|
||||
UPDATE system.accounts
|
||||
SET email_confirmed = true
|
||||
WHERE email = @Email AND security_stamp = @Token";
|
||||
|
||||
var affectedRows = await db.Connection.ExecuteSqlAsync(sql, new
|
||||
{
|
||||
command.Email,
|
||||
command.Token
|
||||
});
|
||||
|
||||
if (affectedRows == 0)
|
||||
{
|
||||
throw new InvalidTokenException();
|
||||
}
|
||||
|
||||
return new CommandResponse(command.CorrelationId, command.GetType().Name, command.TransactionId);
|
||||
}
|
||||
}
|
||||
|
||||
public class InvalidTokenException : Exception
|
||||
{
|
||||
public InvalidTokenException() : base("Invalid or expired verification token")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,30 @@
|
|||
using Insight.Database;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Npgsql;
|
||||
using PlanTempus.Components.Accounts.Exceptions;
|
||||
using PlanTempus.Core;
|
||||
using PlanTempus.Core.CommandQueries;
|
||||
using PlanTempus.Core.Database;
|
||||
using PlanTempus.Core.Outbox;
|
||||
|
||||
namespace PlanTempus.Components.Accounts.Create
|
||||
{
|
||||
public class CreateAccountHandler(
|
||||
IDatabaseOperations databaseOperations,
|
||||
ISecureTokenizer secureTokenizer) : ICommandHandler<CreateAccountCommand>
|
||||
ISecureTokenizer secureTokenizer,
|
||||
IOutboxService outboxService) : ICommandHandler<CreateAccountCommand>
|
||||
{
|
||||
public async Task<CommandResponse> Handle(CreateAccountCommand command)
|
||||
{
|
||||
using var db = databaseOperations.CreateScope(nameof(CreateAccountHandler));
|
||||
using var transaction = db.Connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
var securityStamp = Guid.NewGuid().ToString("N");
|
||||
|
||||
var sql = @"
|
||||
INSERT INTO system.accounts(email , password_hash, security_stamp, email_confirmed,
|
||||
access_failed_count, lockout_enabled,
|
||||
is_active)
|
||||
INSERT INTO system.accounts(email, password_hash, security_stamp, email_confirmed,
|
||||
access_failed_count, lockout_enabled, is_active)
|
||||
VALUES(@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed,
|
||||
@AccessFailedCount, @LockoutEnabled, @IsActive)
|
||||
RETURNING id, created_at, email, is_active";
|
||||
|
|
@ -29,22 +33,36 @@ namespace PlanTempus.Components.Accounts.Create
|
|||
{
|
||||
command.Email,
|
||||
PasswordHash = secureTokenizer.TokenizeText(command.Password),
|
||||
SecurityStamp = Guid.NewGuid().ToString("N"),
|
||||
SecurityStamp = securityStamp,
|
||||
EmailConfirmed = false,
|
||||
AccessFailedCount = 0,
|
||||
LockoutEnabled = false,
|
||||
command.IsActive,
|
||||
});
|
||||
|
||||
await outboxService.EnqueueAsync(
|
||||
OutboxMessageTypes.VerificationEmail,
|
||||
new VerificationEmailPayload
|
||||
{
|
||||
Email = command.Email,
|
||||
UserName = command.Email,
|
||||
Token = securityStamp
|
||||
},
|
||||
db.Connection,
|
||||
transaction);
|
||||
|
||||
transaction.Commit();
|
||||
return new CommandResponse(command.CorrelationId, command.GetType().Name, command.TransactionId);
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == "23505" && ex.ConstraintName.Equals("accounts_email_key", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
transaction.Rollback();
|
||||
db.Error(ex);
|
||||
throw new EmailAlreadyRegistreredException();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
db.Error(ex);
|
||||
throw;
|
||||
}
|
||||
|
|
|
|||
82
PlanTempus.Components/Outbox/OutboxListener.cs
Normal file
82
PlanTempus.Components/Outbox/OutboxListener.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Npgsql;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Components.Outbox;
|
||||
|
||||
public class OutboxListener : BackgroundService
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly ICommandHandler _commandHandler;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
|
||||
public OutboxListener(
|
||||
IDbConnectionFactory connectionFactory,
|
||||
ICommandHandler commandHandler,
|
||||
TelemetryClient telemetryClient)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_commandHandler = commandHandler;
|
||||
_telemetryClient = telemetryClient;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_telemetryClient.TrackTrace("OutboxListener starting - listening for outbox_messages");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ListenForNotificationsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_telemetryClient.TrackException(ex);
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ListenForNotificationsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var conn = (NpgsqlConnection)_connectionFactory.Create();
|
||||
await conn.OpenAsync(stoppingToken);
|
||||
|
||||
conn.Notification += async (_, e) =>
|
||||
{
|
||||
_telemetryClient.TrackTrace($"Outbox notification received: {e.Payload}");
|
||||
|
||||
try
|
||||
{
|
||||
await _commandHandler.Handle(new ProcessOutboxCommand());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_telemetryClient.TrackException(ex);
|
||||
}
|
||||
};
|
||||
|
||||
await using (var cmd = new NpgsqlCommand("LISTEN outbox_messages;", conn))
|
||||
{
|
||||
await cmd.ExecuteNonQueryAsync(stoppingToken);
|
||||
}
|
||||
|
||||
_telemetryClient.TrackTrace("OutboxListener now listening on outbox_messages channel");
|
||||
|
||||
// Process any pending messages on startup
|
||||
await _commandHandler.Handle(new ProcessOutboxCommand());
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await conn.WaitAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_telemetryClient.TrackTrace("OutboxListener stopping");
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
14
PlanTempus.Components/Outbox/OutboxListenerModule.cs
Normal file
14
PlanTempus.Components/Outbox/OutboxListenerModule.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Autofac;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace PlanTempus.Components.Outbox;
|
||||
|
||||
public class OutboxListenerModule : Module
|
||||
{
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterType<OutboxListener>()
|
||||
.As<IHostedService>()
|
||||
.SingleInstance();
|
||||
}
|
||||
}
|
||||
10
PlanTempus.Components/Outbox/ProcessOutboxCommand.cs
Normal file
10
PlanTempus.Components/Outbox/ProcessOutboxCommand.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using PlanTempus.Core.CommandQueries;
|
||||
|
||||
namespace PlanTempus.Components.Outbox;
|
||||
|
||||
public class ProcessOutboxCommand : ICommand
|
||||
{
|
||||
public Guid CorrelationId { get; set; } = Guid.NewGuid();
|
||||
public Guid TransactionId { get; set; } = Guid.NewGuid();
|
||||
public int BatchSize { get; set; } = 10;
|
||||
}
|
||||
71
PlanTempus.Components/Outbox/ProcessOutboxHandler.cs
Normal file
71
PlanTempus.Components/Outbox/ProcessOutboxHandler.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using PlanTempus.Core.CommandQueries;
|
||||
using PlanTempus.Core.Email;
|
||||
using PlanTempus.Core.Outbox;
|
||||
|
||||
namespace PlanTempus.Components.Outbox;
|
||||
|
||||
public class ProcessOutboxHandler(
|
||||
IOutboxService outboxService,
|
||||
IEmailService emailService,
|
||||
TelemetryClient telemetryClient) : ICommandHandler<ProcessOutboxCommand>
|
||||
{
|
||||
public async Task<CommandResponse> Handle(ProcessOutboxCommand command)
|
||||
{
|
||||
telemetryClient.TrackTrace($"ProcessOutboxHandler started");
|
||||
|
||||
var messages = await outboxService.GetPendingAsync(command.BatchSize);
|
||||
|
||||
telemetryClient.TrackTrace($"ProcessOutboxHandler found {messages.Count} pending messages");
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
try
|
||||
{
|
||||
telemetryClient.TrackTrace($"Processing message {message.Id} of type {message.Type}");
|
||||
await ProcessMessageAsync(message);
|
||||
await outboxService.MarkAsSentAsync(message.Id);
|
||||
telemetryClient.TrackTrace($"Message {message.Id} marked as sent");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
telemetryClient.TrackTrace($"Message {message.Id} failed: {ex.Message}");
|
||||
await outboxService.MarkAsFailedAsync(message.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return new CommandResponse(command.CorrelationId, nameof(ProcessOutboxCommand), command.TransactionId);
|
||||
}
|
||||
|
||||
private async Task ProcessMessageAsync(OutboxMessage message)
|
||||
{
|
||||
switch (message.Type)
|
||||
{
|
||||
case OutboxMessageTypes.VerificationEmail:
|
||||
await ProcessVerificationEmailAsync(message);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown outbox message type: {message.Type}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessVerificationEmailAsync(OutboxMessage message)
|
||||
{
|
||||
var payload = ((JsonElement)message.Payload).Deserialize<VerificationEmailPayload>()
|
||||
?? throw new InvalidOperationException("Invalid verification email payload");
|
||||
|
||||
var verifyUrl = $"https://plantempus.dk/confirm-email?token={payload.Token}";
|
||||
|
||||
var result = await emailService.SendVerificationEmailAsync(
|
||||
payload.Email,
|
||||
payload.UserName,
|
||||
verifyUrl);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to send email: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue