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>
75 lines
2.9 KiB
C#
75 lines
2.9 KiB
C#
using System.Data;
|
|
using Dapper;
|
|
using Npgsql;
|
|
|
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
|
|
|
public interface IRegisterIdentitySql
|
|
{
|
|
Task<bool> EmailExistsAsync(string email, CancellationToken ct);
|
|
Task<Guid> CreateIdentityAsync(NpgsqlConnection conn, NpgsqlTransaction transaction, Guid id, string email, string displayName, string correlationId, CancellationToken ct);
|
|
Task<(Guid Id, string Email, string DisplayName, string State)> GetIdentityAsync(Guid id, CancellationToken ct);
|
|
}
|
|
|
|
public sealed class RegisterIdentitySql : IRegisterIdentitySql
|
|
{
|
|
private readonly Func<Task<NpgsqlConnection>> _connectionFactory;
|
|
|
|
public RegisterIdentitySql(Func<Task<NpgsqlConnection>> connectionFactory)
|
|
{
|
|
_connectionFactory = connectionFactory;
|
|
}
|
|
|
|
public async Task<bool> EmailExistsAsync(string email, CancellationToken ct)
|
|
{
|
|
using var conn = await _connectionFactory();
|
|
const string sql = """
|
|
SELECT EXISTS(SELECT 1 FROM public.identity WHERE email = @email)
|
|
""";
|
|
return await conn.QuerySingleAsync<bool>(sql, new { email }, commandTimeout: 5);
|
|
}
|
|
|
|
public async Task<Guid> CreateIdentityAsync(NpgsqlConnection conn, NpgsqlTransaction transaction, Guid id, string email, string displayName, string correlationId, CancellationToken ct)
|
|
{
|
|
const string sql = """
|
|
INSERT INTO public.identity (identity_id, email, display_name, state, created_at, updated_at, published_at, revision_version, correlation_id, username)
|
|
VALUES (@id, @email, @displayName, @state, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, @correlationId, @email)
|
|
ON CONFLICT (email) DO NOTHING
|
|
RETURNING identity_id;
|
|
""";
|
|
|
|
var result = await conn.QuerySingleOrDefaultAsync<Guid?>(
|
|
new CommandDefinition(
|
|
sql,
|
|
new
|
|
{
|
|
id,
|
|
email,
|
|
displayName,
|
|
state = Domain.IdentityState.Active,
|
|
correlationId
|
|
},
|
|
transaction,
|
|
commandTimeout: 5,
|
|
cancellationToken: ct));
|
|
|
|
return result ?? Guid.Empty;
|
|
}
|
|
|
|
public async Task<(Guid Id, string Email, string DisplayName, string State)> GetIdentityAsync(Guid id, CancellationToken ct)
|
|
{
|
|
using var conn = await _connectionFactory();
|
|
const string sql = """
|
|
SELECT identity_id, email, display_name, state
|
|
FROM public.identity
|
|
WHERE identity_id = @id
|
|
""";
|
|
|
|
var row = await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { id }, commandTimeout: 5);
|
|
if (row is null)
|
|
throw new InvalidOperationException($"Identity {id} not found");
|
|
|
|
return ((Guid)row.identity_id, (string)row.email, (string)row.display_name, (string)row.state);
|
|
}
|
|
}
|