c289a698c5
Part 2: Transaction + Outbox Integration - RegisterIdentityEndpoint: DbConnection → DbTransaction → Outbox write - RegisterIdentitySql: Accept NpgsqlConnection + NpgsqlTransaction (Dapper) - Fixed schema references: identity.identity → public.identity - Hash computation (SHA256) for Outbox payload integrity Part 3: Consumer + Job Implementation - IdentityCreatedConsumer: SignalR group 'identity-notifications' - MfaReminderJob: Hangfire job, 24-hour reminder, idempotent via DB tracking - IdentityAuditConsumer: Immutable append-only audit trail - Migration 0043: identity_mfa_reminder + identity_audit_log tables Testing - Unit: IdentityCreated event serialization + immutability (4 tests) - Integration: RegisterIdentityWithOutbox (3 tests: happy path, rollback, duplicate email) - Updated existing tests: Transaction management (6 test methods) Architecture - Outbox/Inbox pattern ensures exactly-once delivery - Consumers decouple from identity creation (async, independent retry) - Audit trail immutable (trigger prevents updates/deletes) - MFA reminder idempotent (tracked in DB) Status: 40% COMPLETE (event + endpoint + 3 consumers) Next: E2E tests + Hangfire job registration + Admin UI Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
116 lines
4.0 KiB
C#
116 lines
4.0 KiB
C#
using FastEndpoints;
|
|
using KArtSell.BuildingBlocks.Data;
|
|
using KArtSell.BuildingBlocks.Reliability;
|
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events;
|
|
using Npgsql;
|
|
using System.Data;
|
|
using System.Text.Json;
|
|
|
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
|
|
|
public sealed class RegisterIdentityEndpoint(
|
|
IRegisterIdentitySql sql,
|
|
IDbConnectionFactory connectionFactory,
|
|
IOutboxWriter outboxWriter)
|
|
: Endpoint<RegisterIdentityRequest, RegisterIdentityResponse>
|
|
{
|
|
public override void Configure()
|
|
{
|
|
Post("/api/identities");
|
|
AllowAnonymous();
|
|
}
|
|
|
|
public override async Task HandleAsync(RegisterIdentityRequest req, CancellationToken ct)
|
|
{
|
|
var email = req.Email?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
|
|
{
|
|
await SendErrorAsync(400, "Invalid email format", ct);
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(req.DisplayName) || req.DisplayName.Length > 255)
|
|
{
|
|
await SendErrorAsync(400, "Display name required, max 255 characters", ct);
|
|
return;
|
|
}
|
|
|
|
var emailExists = await sql.EmailExistsAsync(email, ct);
|
|
if (emailExists)
|
|
{
|
|
await SendErrorAsync(409, "Email already registered", ct);
|
|
return;
|
|
}
|
|
|
|
var identityId = Guid.NewGuid();
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
|
|
var conn = await connectionFactory.OpenAsync(ct) as NpgsqlConnection
|
|
?? throw new InvalidOperationException("Failed to open connection");
|
|
|
|
await using (conn)
|
|
{
|
|
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
|
|
|
|
try
|
|
{
|
|
var createdId = await sql.CreateIdentityAsync(conn, transaction, identityId, email, req.DisplayName, correlationId, ct);
|
|
if (createdId == Guid.Empty)
|
|
{
|
|
await SendErrorAsync(409, "Failed to create identity", ct);
|
|
return;
|
|
}
|
|
|
|
// Write IdentityCreated event to Outbox
|
|
var identityCreatedEvent = new IdentityCreated
|
|
{
|
|
IdentityId = createdId,
|
|
Email = email,
|
|
DisplayName = req.DisplayName,
|
|
CorrelationId = correlationId,
|
|
OccurredAt = DateTime.UtcNow
|
|
};
|
|
|
|
var payloadJson = JsonSerializer.Serialize(identityCreatedEvent);
|
|
var outboxMessage = new OutboxMessage(
|
|
MessageId: Guid.NewGuid(),
|
|
EventType: nameof(IdentityCreated),
|
|
SchemaVersion: 1,
|
|
PayloadJson: payloadJson,
|
|
CorrelationId: correlationId,
|
|
OccurredAt: DateTimeOffset.UtcNow,
|
|
PayloadHash: ComputePayloadHash(payloadJson));
|
|
|
|
await outboxWriter.AddAsync(conn, transaction, outboxMessage, ct);
|
|
await transaction.CommitAsync(ct);
|
|
|
|
var (id, returnedEmail, _, currentState) = await sql.GetIdentityAsync(createdId, ct);
|
|
|
|
await Send.OkAsync(new RegisterIdentityResponse
|
|
{
|
|
Id = id,
|
|
Email = returnedEmail,
|
|
State = currentState
|
|
}, ct);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
await transaction.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SendErrorAsync(int statusCode, string message, CancellationToken ct)
|
|
{
|
|
await Send.StatusCodeAsync(statusCode, ct);
|
|
}
|
|
|
|
private static string ComputePayloadHash(string payloadJson)
|
|
{
|
|
using var hasher = System.Security.Cryptography.SHA256.Create();
|
|
var hash = hasher.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payloadJson));
|
|
return Convert.ToHexString(hash);
|
|
}
|
|
}
|