257 lines
8.5 KiB
C#

using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Newsletter;
using MemberCenter.Domain.Constants;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace MemberCenter.Infrastructure.Services;
public sealed class NewsletterService : INewsletterService
{
private const string ConfirmTokenPurpose = "confirm";
private const string UnsubscribeTokenPurpose = "unsubscribe";
private const int ConfirmTokenTtlDays = 7;
private const int UnsubscribeTokenTtlDays = 7;
private readonly MemberCenterDbContext _dbContext;
private readonly IEmailBlacklistService _emailBlacklist;
public NewsletterService(MemberCenterDbContext dbContext, IEmailBlacklistService emailBlacklist)
{
_dbContext = dbContext;
_emailBlacklist = emailBlacklist;
}
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
{
if (await _emailBlacklist.IsBlacklistedAsync(email))
{
return null;
}
var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId);
if (list is null)
{
return null;
}
var subscription = await _dbContext.NewsletterSubscriptions
.FirstOrDefaultAsync(s => s.ListId == listId && s.Email == email);
if (subscription is null)
{
subscription = new NewsletterSubscription
{
Id = Guid.NewGuid(),
ListId = listId,
Email = email,
Status = SubscriptionStatus.Pending,
Preferences = ToJsonDocument(preferences)
};
_dbContext.NewsletterSubscriptions.Add(subscription);
}
else
{
// Keep active subscriptions active; only pending/unsubscribed subscriptions need reconfirmation.
if (!string.Equals(subscription.Status, SubscriptionStatus.Active, StringComparison.OrdinalIgnoreCase))
{
subscription.Status = SubscriptionStatus.Pending;
}
subscription.Preferences = ToJsonDocument(preferences);
}
var confirmToken = CreateToken();
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(confirmToken, ConfirmTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(ConfirmTokenTtlDays)
});
await _dbContext.SaveChangesAsync();
return new PendingSubscriptionResult(MapSubscription(subscription), confirmToken);
}
public async Task<SubscriptionDto?> ConfirmAsync(string token)
{
var confirmToken = await FindTokenAsync(token, ConfirmTokenPurpose);
if (confirmToken is null && token.Contains(' '))
{
// Backward compatibility: legacy Base64 tokens may decode '+' as space in query string.
confirmToken = await FindTokenAsync(token.Replace(' ', '+'), ConfirmTokenPurpose);
}
if (confirmToken?.Subscription is null)
{
return null;
}
if (await _emailBlacklist.IsBlacklistedAsync(confirmToken.Subscription.Email))
{
return null;
}
if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return null;
}
confirmToken.Subscription.Status = SubscriptionStatus.Active;
confirmToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return MapSubscription(confirmToken.Subscription);
}
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
{
var tokenHash = HashToken(token, UnsubscribeTokenPurpose);
var unsubscribeToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
if (unsubscribeToken?.Subscription is null)
{
return null;
}
if (await _emailBlacklist.IsBlacklistedAsync(unsubscribeToken.Subscription.Email))
{
return null;
}
unsubscribeToken.Subscription.Status = SubscriptionStatus.Unsubscribed;
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return MapSubscription(unsubscribeToken.Subscription);
}
public async Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email)
{
if (await _emailBlacklist.IsBlacklistedAsync(email))
{
return null;
}
var subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.ListId == listId && s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (subscription is null)
{
return null;
}
var token = CreateToken();
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(token, UnsubscribeTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(UnsubscribeTokenTtlDays)
});
await _dbContext.SaveChangesAsync();
return token;
}
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email)
{
if (await _emailBlacklist.IsBlacklistedAsync(email))
{
return null;
}
var subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.ListId == listId && s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
return subscription is null ? null : MapSubscription(subscription);
}
public async Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences)
{
if (await _emailBlacklist.IsBlacklistedAsync(email))
{
return null;
}
var subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.ListId == listId && s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (subscription is null)
{
return null;
}
subscription.Preferences = ToJsonDocument(preferences);
await _dbContext.SaveChangesAsync();
return MapSubscription(subscription);
}
public async Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId)
{
var blacklisted = _dbContext.EmailBlacklist.Select(x => x.Email);
var subscriptions = await _dbContext.NewsletterSubscriptions
.Where(s => s.ListId == listId && !blacklisted.Contains(s.Email.ToLower()))
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
return subscriptions.Select(MapSubscription).ToList();
}
private static string CreateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static string HashToken(string token, string purpose)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes($"{purpose}:{token}"));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static JsonDocument ToJsonDocument(Dictionary<string, object>? value)
{
if (value is null)
{
return JsonDocument.Parse("{}");
}
return JsonDocument.Parse(JsonSerializer.Serialize(value));
}
private static SubscriptionDto MapSubscription(NewsletterSubscription subscription)
{
return new SubscriptionDto(
subscription.Id,
subscription.ListId,
subscription.Email,
subscription.Status,
subscription.Preferences.RootElement.Clone(),
subscription.CreatedAt);
}
private Task<UnsubscribeToken?> FindTokenAsync(string token, string purpose)
{
var tokenHash = HashToken(token, purpose);
return _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
}
}