192 lines
7.1 KiB
C#
192 lines
7.1 KiB
C#
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<string> AllowedReasons = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"hard_bounce",
|
|
"soft_bounce_threshold",
|
|
"complaint",
|
|
"suppression"
|
|
};
|
|
private static readonly HashSet<string> 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<SubscriptionsController> _logger;
|
|
|
|
public SubscriptionsController(
|
|
IEmailBlacklistService emailBlacklistService,
|
|
MemberCenterDbContext dbContext,
|
|
IConfiguration configuration,
|
|
ILogger<SubscriptionsController> logger)
|
|
{
|
|
_emailBlacklistService = emailBlacklistService;
|
|
_dbContext = dbContext;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
}
|
|
|
|
[Authorize]
|
|
[HttpPost("disable")]
|
|
public async Task<IActionResult> 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<bool>(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);
|
|
}
|
|
}
|