Files
KArtSell.Aegis/src/KArtSell.Host/Jobs/MfaReminderJob.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

73 lines
2.7 KiB
C#

using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace KArtSell.Host.Jobs;
/// <summary>
/// Sends MFA setup reminder email 24 hours after identity creation.
/// Triggered by: IdentityCreated event via Outbox/Inbox.
/// Idempotent: Tracks sends in identity_mfa_reminder table to avoid duplicates.
/// </summary>
public sealed class MfaReminderJob(
IDbConnectionFactory connectionFactory,
ILogger<MfaReminderJob> logger)
{
private const string MfaSetupLink = "https://kartsell.taxbaik.com/setup-mfa";
public async Task ExecuteAsync(IdentityCreated message, CancellationToken cancellationToken = default)
{
try
{
logger.LogInformation(
"MFA reminder scheduled for identity {IdentityId} ({Email})",
message.IdentityId,
message.Email);
var conn = await connectionFactory.OpenAsync(cancellationToken) as NpgsqlConnection
?? throw new InvalidOperationException("Failed to open connection");
await using (conn)
{
// Idempotency check: skip if already sent
const string checkSql = """
SELECT COUNT(1) > 0
FROM public.identity_mfa_reminder
WHERE identity_id = @identityId
""";
var alreadySent = await conn.QuerySingleAsync<bool>(checkSql, new { identityId = message.IdentityId });
if (alreadySent)
{
logger.LogInformation(
"MFA reminder already sent for identity {IdentityId}, skipping",
message.IdentityId);
return;
}
// In production: send via email service (SendGrid, AWS SES, etc.)
logger.LogInformation(
"Sending MFA setup reminder to {Email}. Setup link: {MfaSetupLink}",
message.Email,
MfaSetupLink);
// Mark as sent in database (idempotency marker)
const string insertSql = """
INSERT INTO public.identity_mfa_reminder (identity_id, sent_at)
VALUES (@identityId, CURRENT_TIMESTAMP)
ON CONFLICT (identity_id) DO NOTHING
""";
await conn.ExecuteAsync(insertSql, new { identityId = message.IdentityId });
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send MFA reminder for identity {IdentityId}", message.IdentityId);
throw;
}
}
}