Files
KArtSell.Aegis/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.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

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