Files
KArtSell.Aegis/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs
T
kjh2064 c289a698c5 feat(wbs): AEG-VS-01-05 Event/Job/Inbox - Part 2-3 Complete (Outbox/Inbox + Consumers)
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>
2026-08-17 18:41:10 +09:00

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);
}
}