member_center/src/MemberCenter.Api/Controllers/SubscriptionsController.cs
2026-07-07 13:27:49 +09:00

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);
}
}