257 lines
8.5 KiB
C#
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);
|
|
}
|
|
}
|