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>
This commit is contained in:
+25
-19
@@ -1,3 +1,4 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
@@ -6,7 +7,7 @@ namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.Regist
|
||||
public interface IRegisterIdentitySql
|
||||
{
|
||||
Task<bool> EmailExistsAsync(string email, CancellationToken ct);
|
||||
Task<Guid> CreateIdentityAsync(Guid id, string email, string displayName, string correlationId, 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);
|
||||
}
|
||||
|
||||
@@ -23,29 +24,34 @@ public sealed class RegisterIdentitySql : IRegisterIdentitySql
|
||||
{
|
||||
using var conn = await _connectionFactory();
|
||||
const string sql = """
|
||||
SELECT EXISTS(SELECT 1 FROM identity.identity WHERE email = @email)
|
||||
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(Guid id, string email, string displayName, string correlationId, CancellationToken ct)
|
||||
public async Task<Guid> CreateIdentityAsync(NpgsqlConnection conn, NpgsqlTransaction transaction, Guid id, string email, string displayName, string correlationId, CancellationToken ct)
|
||||
{
|
||||
using var conn = await _connectionFactory();
|
||||
const string sql = """
|
||||
INSERT INTO identity.identity (id, email, display_name, state, created_at, updated_at, published_at, revision_version, correlation_id)
|
||||
VALUES (@id, @email, @displayName, @state, NOW(), NOW(), NOW(), 1, @correlationId)
|
||||
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 id;
|
||||
RETURNING identity_id;
|
||||
""";
|
||||
|
||||
var result = await conn.QuerySingleOrDefaultAsync<Guid?>(sql, new
|
||||
{
|
||||
id,
|
||||
email,
|
||||
displayName,
|
||||
state = Domain.IdentityState.Active,
|
||||
correlationId
|
||||
}, commandTimeout: 5);
|
||||
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;
|
||||
}
|
||||
@@ -54,15 +60,15 @@ public sealed class RegisterIdentitySql : IRegisterIdentitySql
|
||||
{
|
||||
using var conn = await _connectionFactory();
|
||||
const string sql = """
|
||||
SELECT id, email, display_name, state
|
||||
FROM identity.identity
|
||||
WHERE id = @id
|
||||
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.id, (string)row.email, (string)row.display_name, (string)row.state);
|
||||
return ((Guid)row.identity_id, (string)row.email, (string)row.display_name, (string)row.state);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user