using System.Text.Json; using MemberCenter.Application.Abstractions; using MemberCenter.Application.Models.Account; using MemberCenter.Domain.Entities; using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using MemberCenter.Application.Models.Newsletter; namespace MemberCenter.Infrastructure.Services; public sealed class AccountProvisioningService : IAccountProvisioningService { private readonly UserManager _userManager; private readonly MemberCenterDbContext _dbContext; private readonly ISendEngineWebhookPublisher _webhookPublisher; private readonly ILogger _logger; public AccountProvisioningService( UserManager userManager, MemberCenterDbContext dbContext, ISendEngineWebhookPublisher webhookPublisher, ILogger logger) { _userManager = userManager; _dbContext = dbContext; _webhookPublisher = webhookPublisher; _logger = logger; } public async Task RegisterLocalAsync(string email, string password) { var normalizedEmail = NormalizeEmail(email); var user = new ApplicationUser { Id = Guid.NewGuid(), UserName = normalizedEmail, Email = normalizedEmail, EmailConfirmed = false }; var result = await _userManager.CreateAsync(user, password); if (!result.Succeeded) { return Failed(result); } var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(user, "local_registration", new { email = normalizedEmail }); return new AccountProvisioningResult( true, user.Id, user.Email, user.EmailConfirmed, true, false, linkedSubscriptionsCount, Array.Empty()); } public async Task ProvisionExternalLoginAsync( string loginProvider, string providerKey, string? email, bool emailVerified) { var existingByLogin = await _userManager.FindByLoginAsync(loginProvider, providerKey); if (existingByLogin is not null) { var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(existingByLogin, "external_login_reuse", new { login_provider = loginProvider }); return new AccountProvisioningResult( true, existingByLogin.Id, existingByLogin.Email, existingByLogin.EmailConfirmed, false, false, linkedSubscriptionsCount, Array.Empty()); } if (string.IsNullOrWhiteSpace(email)) { return new AccountProvisioningResult( false, null, null, false, false, false, 0, new[] { "External login did not provide an email address." }); } var normalizedEmail = NormalizeEmail(email); var user = await _userManager.FindByEmailAsync(normalizedEmail); var createdUser = false; if (user is null) { user = new ApplicationUser { Id = Guid.NewGuid(), UserName = normalizedEmail, Email = normalizedEmail, EmailConfirmed = emailVerified }; var createResult = await _userManager.CreateAsync(user); if (!createResult.Succeeded) { return Failed(createResult); } createdUser = true; } else if (emailVerified && !user.EmailConfirmed) { user.EmailConfirmed = true; await _userManager.UpdateAsync(user); } var addLoginResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(loginProvider, providerKey, loginProvider)); if (!addLoginResult.Succeeded && addLoginResult.Errors.All(x => x.Code != nameof(IdentityErrorDescriber.LoginAlreadyAssociated))) { return Failed(addLoginResult); } var linkedSubscriptions = await LinkSubscriptionsAndAuditAsync(user, "external_login_linked", new { login_provider = loginProvider, created_user = createdUser }); return new AccountProvisioningResult( true, user.Id, user.Email, user.EmailConfirmed, createdUser, true, linkedSubscriptions, Array.Empty()); } private async Task LinkSubscriptionsAndAuditAsync(ApplicationUser user, string source, object payload) { var email = NormalizeEmail(user.Email ?? user.UserName ?? string.Empty); var subscriptions = await _dbContext.NewsletterSubscriptions .Where(x => x.UserId == null && x.Email.ToLower() == email) .ToListAsync(); if (subscriptions.Count == 0) { return 0; } foreach (var subscription in subscriptions) { subscription.UserId = user.Id; } var linkedSubscriptions = subscriptions .Select(subscription => new SubscriptionDto( subscription.Id, subscription.ListId, subscription.Email, subscription.Status, subscription.Preferences.RootElement.Clone(), subscription.CreatedAt)) .ToList(); _dbContext.AuditLogs.Add(new AuditLog { Id = Guid.NewGuid(), ActorType = "system", ActorId = user.Id, Action = "subscription.linked_to_user", Payload = JsonDocument.Parse(JsonSerializer.Serialize(new { user_id = user.Id, email, linked_subscriptions = subscriptions.Count, source, metadata = payload })) }); await _dbContext.SaveChangesAsync(); await PublishLinkedSubscriptionEventsAsync(linkedSubscriptions); return subscriptions.Count; } private async Task PublishLinkedSubscriptionEventsAsync(IReadOnlyList subscriptions) { if (subscriptions.Count == 0) { return; } var listIds = subscriptions.Select(x => x.ListId).Distinct().ToList(); var tenantMap = await _dbContext.NewsletterLists .Where(x => listIds.Contains(x.Id)) .ToDictionaryAsync(x => x.Id, x => x.TenantId); foreach (var subscription in subscriptions) { if (!tenantMap.TryGetValue(subscription.ListId, out var tenantId) || tenantId == Guid.Empty) { _logger.LogWarning( "Skip linked subscription event because list {ListId} has no tenant mapping", subscription.ListId); continue; } await _webhookPublisher.PublishSubscriptionEventAsync("subscription.linked_to_user", tenantId, subscription); } } private static AccountProvisioningResult Failed(IdentityResult result) => new( false, null, null, false, false, false, 0, result.Errors.Select(x => x.Description).ToArray()); private static string NormalizeEmail(string email) => email.Trim().ToLowerInvariant(); }