using Dapper; using KArtSell.BuildingBlocks.Data; using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events; using Microsoft.Extensions.Logging; using Npgsql; namespace KArtSell.Host.Jobs; /// /// 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. /// public sealed class MfaReminderJob( IDbConnectionFactory connectionFactory, ILogger 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(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; } } }