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:
Janus C. H. Knudsen 2026-01-10 11:13:33 +01:00
parent 88812177a9
commit 54b057886c
35 changed files with 1174 additions and 358 deletions

View file

@ -0,0 +1,13 @@
#nullable enable
using System.Data;
namespace PlanTempus.Core.Outbox;
public interface IOutboxService
{
Task EnqueueAsync(string type, object payload, IDbConnection? connection = null, IDbTransaction? transaction = null);
Task<List<OutboxMessage>> GetPendingAsync(int batchSize = 10);
Task MarkAsSentAsync(Guid id);
Task MarkAsFailedAsync(Guid id, string errorMessage);
}

View file

@ -0,0 +1,27 @@
#nullable enable
namespace PlanTempus.Core.Outbox;
public class OutboxMessage
{
public Guid Id { get; set; }
public required string Type { get; set; }
public required object Payload { get; set; }
public string Status { get; set; } = "pending";
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
public int RetryCount { get; set; }
public string? ErrorMessage { get; set; }
}
public static class OutboxMessageTypes
{
public const string VerificationEmail = "verification_email";
}
public class VerificationEmailPayload
{
public required string Email { get; set; }
public required string UserName { get; set; }
public required string Token { get; set; }
}

View file

@ -0,0 +1,11 @@
using Autofac;
namespace PlanTempus.Core.Outbox;
public class OutboxModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<OutboxService>().As<IOutboxService>().InstancePerLifetimeScope();
}
}

View file

@ -0,0 +1,104 @@
#nullable enable
using System.Data;
using System.Text.Json;
using Insight.Database;
using PlanTempus.Core.Database;
namespace PlanTempus.Core.Outbox;
public class OutboxService(IDatabaseOperations databaseOperations) : IOutboxService
{
public async Task EnqueueAsync(string type, object payload, IDbConnection? connection = null, IDbTransaction? transaction = null)
{
var sql = @"
INSERT INTO system.outbox (type, payload)
VALUES (@Type, @Payload::jsonb)";
var parameters = new
{
Type = type,
Payload = JsonSerializer.Serialize(payload)
};
if (connection != null)
{
await connection.ExecuteSqlAsync(sql, parameters);
}
else
{
using var db = databaseOperations.CreateScope(nameof(OutboxService));
await db.Connection.ExecuteSqlAsync(sql, parameters);
}
}
public async Task<List<OutboxMessage>> GetPendingAsync(int batchSize = 10)
{
using var db = databaseOperations.CreateScope(nameof(OutboxService));
var sql = @"
UPDATE system.outbox
SET status = 'processing'
WHERE id IN (
SELECT id FROM system.outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT @BatchSize
FOR UPDATE SKIP LOCKED
)
RETURNING id, type, payload, status, created_at, processed_at, retry_count, error_message";
var results = await db.Connection.QuerySqlAsync<OutboxMessageDto>(sql, new { BatchSize = batchSize });
return results.Select(r => new OutboxMessage
{
Id = r.Id,
Type = r.Type,
Payload = JsonSerializer.Deserialize<JsonElement>(r.Payload),
Status = r.Status,
CreatedAt = r.CreatedAt,
ProcessedAt = r.ProcessedAt,
RetryCount = r.RetryCount,
ErrorMessage = r.ErrorMessage
}).ToList();
}
public async Task MarkAsSentAsync(Guid id)
{
using var db = databaseOperations.CreateScope(nameof(OutboxService));
var sql = @"
UPDATE system.outbox
SET status = 'sent', processed_at = NOW()
WHERE id = @Id";
await db.Connection.ExecuteSqlAsync(sql, new { Id = id });
}
public async Task MarkAsFailedAsync(Guid id, string errorMessage)
{
using var db = databaseOperations.CreateScope(nameof(OutboxService));
var sql = @"
UPDATE system.outbox
SET status = 'failed',
processed_at = NOW(),
retry_count = retry_count + 1,
error_message = @ErrorMessage
WHERE id = @Id";
await db.Connection.ExecuteSqlAsync(sql, new { Id = id, ErrorMessage = errorMessage });
}
private class OutboxMessageDto
{
public Guid Id { get; set; }
public string Type { get; set; } = "";
public string Payload { get; set; } = "";
public string Status { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
public int RetryCount { get; set; }
public string? ErrorMessage { get; set; }
}
}