using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; using MemberCenter.Domain.Constants; using MemberCenter.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using OpenIddict.Abstractions; namespace MemberCenter.Api.Controllers; [ApiController] [Route("subscriptions")] public class SubscriptionsController : ControllerBase { private static readonly HashSet AllowedReasons = new(StringComparer.OrdinalIgnoreCase) { "hard_bounce", "soft_bounce_threshold", "complaint", "suppression" }; private static readonly HashSet BlacklistReasons = new(StringComparer.OrdinalIgnoreCase) { "hard_bounce", "soft_bounce_threshold", "suppression" }; // TEST-ONLY SWITCH: keep this key stable so it is easy to find/remove after SES integration test. private const string DisableSubscriptionDryRunNoDbKey = "Testing:DisableSubscriptionDryRunNoDb"; private readonly IEmailBlacklistService _emailBlacklistService; private readonly MemberCenterDbContext _dbContext; private readonly IConfiguration _configuration; private readonly ILogger _logger; public SubscriptionsController( IEmailBlacklistService emailBlacklistService, MemberCenterDbContext dbContext, IConfiguration configuration, ILogger logger) { _emailBlacklistService = emailBlacklistService; _dbContext = dbContext; _configuration = configuration; _logger = logger; } [Authorize] [HttpPost("disable")] public async Task Disable([FromBody] DisableSubscriptionRequest 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.SubscriberId == Guid.Empty || request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Reason) || string.IsNullOrWhiteSpace(request.DisabledBy)) { return BadRequest("tenant_id, subscriber_id, list_id, reason, disabled_by are required."); } if (!AllowedReasons.Contains(request.Reason)) { return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression."); } if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId)) { return Forbid(); } // TEST-ONLY BEHAVIOR: in dry-run mode, do not execute DB read/write; only emit planned operations. if (_configuration.GetValue(DisableSubscriptionDryRunNoDbKey)) { _logger.LogWarning( "TEST-ONLY DRY RUN ENABLED ({ConfigKey}). Skip DB access for /subscriptions/disable. Incoming payload: {@Payload}", DisableSubscriptionDryRunNoDbKey, request); _logger.LogInformation( "TEST-ONLY PLAN: would query newsletter_subscriptions by subscriber_id={SubscriberId}, list_id={ListId}, then verify tenant_id={TenantId}.", request.SubscriberId, request.ListId, request.TenantId); _logger.LogInformation( "TEST-ONLY PLAN: would blacklist resolved email with reason={Reason}, disabled_by={DisabledBy}, occurred_at={OccurredAt}.", request.Reason, request.DisabledBy, request.OccurredAt); return Ok(new { status = "dry_run_no_db", message = "DB access skipped by test flag.", config_key = DisableSubscriptionDryRunNoDbKey }); } var target = await ( from subscription in _dbContext.NewsletterSubscriptions join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id where subscription.Id == request.SubscriberId && subscription.ListId == request.ListId select new { subscription.Email, list.TenantId }) .FirstOrDefaultAsync(); if (target is null) { return NotFound("Subscription not found."); } if (target.TenantId != request.TenantId) { return BadRequest("tenant_id does not match subscription/list tenant boundary."); } var normalizedEmail = target.Email.Trim().ToLowerInvariant(); var shouldBlacklist = BlacklistReasons.Contains(request.Reason); var cancelledCount = 0; if (shouldBlacklist) { // hard/soft/suppression: cancel across all tenants for the same email. var subscriptions = await _dbContext.NewsletterSubscriptions .Where(s => s.Email.ToLower() == normalizedEmail && s.Status != SubscriptionStatus.Unsubscribed) .ToListAsync(); foreach (var subscription in subscriptions) { subscription.Status = SubscriptionStatus.Unsubscribed; } cancelledCount = subscriptions.Count; await _dbContext.SaveChangesAsync(); await _emailBlacklistService.AddOrUpdateAsync( target.Email, request.Reason, request.DisabledBy, request.OccurredAt); } else { // complaint: cancel only the target subscription; do not blacklist. var subscription = await _dbContext.NewsletterSubscriptions .FirstOrDefaultAsync(s => s.Id == request.SubscriberId && s.ListId == request.ListId); if (subscription is not null && subscription.Status != SubscriptionStatus.Unsubscribed) { subscription.Status = SubscriptionStatus.Unsubscribed; cancelledCount = 1; await _dbContext.SaveChangesAsync(); } } return Ok(new { email = target.Email, status = shouldBlacklist ? "unsubscribed_blacklisted" : "unsubscribed", blacklisted = shouldBlacklist, cancelled_count = cancelledCount }); } 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 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); } }