using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; using MemberCenter.Infrastructure.Configuration; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenIddict.Abstractions; using Microsoft.Extensions.Options; namespace MemberCenter.Api.Controllers; [ApiController] [Route("newsletter")] public class NewsletterController : ControllerBase { private readonly INewsletterService _newsletterService; private readonly INewsletterListService _newsletterListService; private readonly NewsletterTokenOptions _tokenOptions; public NewsletterController( INewsletterService newsletterService, INewsletterListService newsletterListService, IOptions tokenOptions) { _newsletterService = newsletterService; _newsletterListService = newsletterListService; _tokenOptions = tokenOptions.Value; } [HttpPost("subscribe")] [Authorize] [EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterSubscribe)] public async Task Subscribe([FromBody] SubscribeRequest request) { var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write"); if (authorizationFailure is not null) { return authorizationFailure; } var result = await _newsletterService.SubscribeAsync(request.ListId, request.Email, request.Preferences); if (result is null) { return NotFound("List not found."); } return Ok(new { id = result.Subscription.Id, list_id = result.Subscription.ListId, email = result.Subscription.Email, status = result.Subscription.Status, created_at = result.Subscription.CreatedAt, confirm_token = result.ConfirmToken }); } [HttpGet("confirm")] public async Task Confirm([FromQuery] string token) { var subscription = await _newsletterService.ConfirmAsync(token); if (subscription is null) { return NotFound("Invalid token."); } return Ok(new { id = subscription.Id, list_id = subscription.ListId, email = subscription.Email, status = subscription.Status, created_at = subscription.CreatedAt }); } [HttpPost("unsubscribe")] public async Task Unsubscribe([FromBody] UnsubscribeRequest request) { var subscription = await _newsletterService.UnsubscribeAsync(request.Token); if (subscription is null) { return NotFound("Invalid token."); } return Ok(new { id = subscription.Id, list_id = subscription.ListId, email = subscription.Email, status = subscription.Status, created_at = subscription.CreatedAt }); } [HttpPost("unsubscribe-token")] [Authorize] [EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken)] public async Task IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request) { if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email)) { return BadRequest("Both list_id and email are required."); } var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write"); if (authorizationFailure is not null) { return authorizationFailure; } var token = await _newsletterService.IssueUnsubscribeTokenAsync(request.ListId, request.Email); if (token is null) { return NotFound("Subscription not found."); } return Ok(new { unsubscribe_token = token }); } [Authorize] [EnableRateLimiting(RateLimitPolicyNames.NewsletterOneClickToken)] [HttpPost("one-click-unsubscribe-token")] public async Task IssueOneClickUnsubscribeToken([FromBody] IssueOneClickUnsubscribeTokenRequest request) { var hasTenantScope = HasScope(User, "newsletter:events.write"); var hasGlobalScope = HasScope(User, "newsletter:events.write.global"); if (!hasTenantScope && !hasGlobalScope) { return Forbid(); } if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.SubscriberId == Guid.Empty) { return BadRequest("tenant_id, list_id, subscriber_id are required."); } if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId)) { return Forbid(); } var token = await _newsletterService.IssueOneClickUnsubscribeTokenAsync(request.TenantId, request.ListId, request.SubscriberId); if (token is null) { return NotFound("Subscription not found."); } return Ok(new { unsubscribe_token = token }); } [Authorize] [EnableRateLimiting(RateLimitPolicyNames.NewsletterOneClickToken)] [HttpPost("one-click-unsubscribe-tokens")] public async Task IssueOneClickUnsubscribeTokens([FromBody] IssueOneClickUnsubscribeTokensRequest request) { var hasTenantScope = HasScope(User, "newsletter:events.write"); var hasGlobalScope = HasScope(User, "newsletter:events.write.global"); if (!hasTenantScope && !hasGlobalScope) { return Forbid(); } if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.SubscriberIds is null || request.SubscriberIds.Count == 0) { return BadRequest("tenant_id, list_id, subscriber_ids are required."); } if (request.SubscriberIds.Count > _tokenOptions.OneClickBatchSizeLimit) { return BadRequest($"subscriber_ids exceeds maximum batch size ({_tokenOptions.OneClickBatchSizeLimit})."); } if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId)) { return Forbid(); } var items = await _newsletterService.IssueOneClickUnsubscribeTokensAsync(request.TenantId, request.ListId, request.SubscriberIds); return Ok(new { items = items.Select(x => new { subscriber_id = x.SubscriberId, unsubscribe_token = x.UnsubscribeToken, status = x.Status }) }); } [HttpGet("preferences")] [Authorize] public async Task Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email) { if (!listId.HasValue || listId.Value == Guid.Empty || string.IsNullOrWhiteSpace(email)) { return BadRequest("Both list_id and email are required."); } var authorizationFailure = await ValidateTenantListAccessAsync(listId.Value, "newsletter:subscriptions.read"); if (authorizationFailure is not null) { return authorizationFailure; } var subscription = await _newsletterService.GetPreferencesAsync(listId.Value, email); if (subscription is null) { return NotFound("Subscription not found."); } return Ok(new { id = subscription.Id, list_id = subscription.ListId, email = subscription.Email, status = subscription.Status, preferences = subscription.Preferences }); } [HttpPost("preferences")] [Authorize] public async Task UpdatePreferences([FromBody] UpdatePreferencesRequest request) { if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email)) { return BadRequest("Both list_id and email are required."); } var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write"); if (authorizationFailure is not null) { return authorizationFailure; } var subscription = await _newsletterService.UpdatePreferencesAsync(request.ListId, request.Email, request.Preferences); if (subscription is null) { return NotFound("Subscription not found."); } return Ok(new { id = subscription.Id, list_id = subscription.ListId, email = subscription.Email, status = subscription.Status, preferences = subscription.Preferences }); } [Authorize] [HttpGet("subscriptions")] public async Task ListSubscriptions([FromQuery(Name = "list_id")] Guid listId) { var hasTenantScope = HasScope(User, "newsletter:list.read"); var hasGlobalScope = HasScope(User, "newsletter:list.read.global"); if (!hasTenantScope && !hasGlobalScope) { return Forbid(); } if (listId == Guid.Empty) { return BadRequest("list_id is required."); } var list = await _newsletterListService.GetAsync(listId); if (list is null) { return NotFound(); } if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != list.TenantId)) { return Forbid(); } var subscriptions = await _newsletterService.ListSubscriptionsAsync(listId); return Ok(subscriptions.Select(s => new { id = s.Id, list_id = s.ListId, email = s.Email, status = s.Status, preferences = s.Preferences, created_at = s.CreatedAt })); } private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope) { var values = user.FindAll(OpenIddictConstants.Claims.Scope) .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)); return values.Contains(scope, StringComparer.Ordinal); } private async Task ValidateTenantListAccessAsync(Guid listId, string scope) { if (!HasScope(User, scope)) { return Forbid(); } if (listId == Guid.Empty) { return BadRequest("list_id is required."); } var list = await _newsletterListService.GetAsync(listId); if (list is null) { return NotFound("List not found."); } return TryGetTenantId(User, out var tenantId) && tenantId == list.TenantId ? null : Forbid(); } private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId) { tenantId = Guid.Empty; var value = user.FindFirst("tenant_id")?.Value; return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out tenantId); } }