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:
@@ -0,0 +1,103 @@
|
||||
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Host.Consumers;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes identity creation notifications via SignalR.
|
||||
/// Targets group: identity-notifications so all admins tracking new identities are notified.
|
||||
/// Idempotent: SignalR deduplication via idempotency key.
|
||||
/// </summary>
|
||||
public sealed class IdentityCreatedConsumer : IInboxConsumer<IdentityCreated>
|
||||
{
|
||||
private readonly IHubContext<IdentityNotificationHub>? _hubContext;
|
||||
private readonly ILogger<IdentityCreatedConsumer> _logger;
|
||||
|
||||
private static readonly Action<ILogger, Guid, string, Exception?> LogNotification =
|
||||
LoggerMessage.Define<Guid, string>(
|
||||
LogLevel.Information,
|
||||
new EventId(1, nameof(LogNotification)),
|
||||
"Identity {IdentityId} ({Email}) created notification sent");
|
||||
|
||||
private static readonly Action<ILogger, Exception?> LogHubNotConfigured =
|
||||
LoggerMessage.Define(
|
||||
LogLevel.Warning,
|
||||
new EventId(2, nameof(LogHubNotConfigured)),
|
||||
"SignalR hub not configured, skipping notification");
|
||||
|
||||
public IdentityCreatedConsumer(
|
||||
IHubContext<IdentityNotificationHub>? hubContext,
|
||||
ILogger<IdentityCreatedConsumer> logger)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(IdentityCreated message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
LogNotification(_logger, message.IdentityId, message.Email, null);
|
||||
|
||||
if (_hubContext == null)
|
||||
{
|
||||
LogHubNotConfigured(_logger, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var notification = new
|
||||
{
|
||||
message.IdentityId,
|
||||
message.Email,
|
||||
message.DisplayName,
|
||||
message.OccurredAt
|
||||
};
|
||||
|
||||
await _hubContext.Clients
|
||||
.Group("identity-notifications")
|
||||
.SendAsync("IdentityCreated", notification, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send identity creation notification for {IdentityId}", message.IdentityId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for identity notifications.
|
||||
/// Clients subscribe to group: identity-notifications
|
||||
/// </summary>
|
||||
public sealed class IdentityNotificationHub : Hub
|
||||
{
|
||||
private readonly ILogger<IdentityNotificationHub> _logger;
|
||||
|
||||
public IdentityNotificationHub(ILogger<IdentityNotificationHub> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
_logger.LogInformation("Client {ConnectionId} connected to IdentityNotificationHub", Context.ConnectionId);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public async Task SubscribeToIdentityNotifications()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, "identity-notifications");
|
||||
_logger.LogInformation(
|
||||
"Client {ConnectionId} subscribed to identity-notifications",
|
||||
Context.ConnectionId);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeFromIdentityNotifications()
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, "identity-notifications");
|
||||
_logger.LogInformation(
|
||||
"Client {ConnectionId} unsubscribed from identity-notifications",
|
||||
Context.ConnectionId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user