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
13
Core/Outbox/IOutboxService.cs
Normal file
13
Core/Outbox/IOutboxService.cs
Normal 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);
|
||||
}
|
||||
27
Core/Outbox/OutboxMessage.cs
Normal file
27
Core/Outbox/OutboxMessage.cs
Normal 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; }
|
||||
}
|
||||
11
Core/Outbox/OutboxModule.cs
Normal file
11
Core/Outbox/OutboxModule.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
104
Core/Outbox/OutboxService.cs
Normal file
104
Core/Outbox/OutboxService.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue