From 5c7d4cdf5a1b0db164eb9b4d3ad9e57d84eb9d76 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Thu, 19 Feb 2026 17:20:17 +0900 Subject: [PATCH] feat: Implement Send Engine webhook client management and update related API documentation --- .env.example | 1 + docs/DESIGN.md | 1 + docs/FLOWS.md | 7 +- docs/INSTALL.md | 10 +- docs/OPENAPI.md | 4 +- docs/UI.md | 1 + docs/openapi.yaml | 34 +++++ .../Contracts/AdminRequests.cs | 2 +- .../Contracts/IntegrationRequests.cs | 7 + .../Controllers/AdminTenantsController.cs | 4 +- .../SendEngineIntegrationController.cs | 54 ++++++++ .../Controllers/SubscriptionsController.cs | 41 +++++- src/MemberCenter.Api/Program.cs | 14 +- .../Abstractions/ITenantService.cs | 5 +- .../Models/Admin/TenantDto.cs | 7 +- .../Configuration/SendEngineWebhookOptions.cs | 1 - .../Services/NewsletterService.cs | 53 +++++++ .../Services/SendEngineWebhookPublisher.cs | 24 +++- .../Services/TenantService.cs | 130 ++++++++++++++++-- .../Controllers/Admin/TenantsController.cs | 36 ++++- .../Models/Admin/TenantFormViewModel.cs | 2 + .../Views/Admin/Tenants/Create.cshtml | 4 + .../Views/Admin/Tenants/Edit.cshtml | 4 + .../Views/Admin/Tenants/Index.cshtml | 3 +- 24 files changed, 415 insertions(+), 34 deletions(-) create mode 100644 src/MemberCenter.Api/Contracts/IntegrationRequests.cs create mode 100644 src/MemberCenter.Api/Controllers/SendEngineIntegrationController.cs diff --git a/.env.example b/.env.example index b4bab16..73a1b31 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ ASPNETCORE_ENVIRONMENT=Development ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres SendEngine__BaseUrl=http://localhost:6060 SendEngine__WebhookSecret=change-me +Testing__DisableSubscriptionDryRunNoDb=false diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 080b9e8..1eb7dfc 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -135,6 +135,7 @@ - POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫) - POST `/webhooks/lists/full-sync`(Send Engine 端點,Member Center 呼叫) - POST `/subscriptions/disable`(Member Center 端點,Send Engine 呼叫) +- POST `/integrations/send-engine/webhook-clients/upsert`(Member Center 端點,Send Engine 呼叫) ## 7.1 待新增 API / Auth(規劃中) ### API diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 9630061..d404127 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -40,7 +40,7 @@ - [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`) - [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id` - [API] `X-Client-Id` 對應 Send Engine `auth_clients.id`(UUID) -- [API] Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 選擇 header 值 +- [API] Member Center 從 DB 的 tenant 設定讀取對應 webhook client id - [API] Send Engine 驗證簽章 + timestamp + nonce(重放防護)後入庫 - [API] Send Engine 更新名單快照 @@ -54,6 +54,11 @@ - [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at` - [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送 +## F-12 Webhook Client Mapping 回填(Send Engine → Member Center) +- [API] Send Engine 建立/更新 tenant 對應的 webhook client(`auth_clients.id`) +- [API] 呼叫 `POST /integrations/send-engine/webhook-clients/upsert`(scope: `newsletter:events.write.global`) +- [API] Member Center 更新 tenant 設定(DB) + ## F-07 會員資料查看 - [API] 站點讀取 `/user/profile` - [UI] 會員中心提供個人資料頁 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 717e31d..e828673 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -42,13 +42,19 @@ ASPNETCORE_ENVIRONMENT=Development ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres SendEngine__BaseUrl=http://localhost:6060 SendEngine__WebhookSecret=change-me -SendEngine__TenantWebhookClientIds__REPLACE_WITH_TENANT_ID=11111111-1111-1111-1111-111111111111 ``` `SendEngine` 設定說明: - `SendEngine__BaseUrl`: Send Engine API base URL - `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致 -- `SendEngine__TenantWebhookClientIds__{tenant_uuid}`: 每個 tenant 對應的 `X-Client-Id`(Send Engine `auth_clients.id`) +- tenant 對應 `X-Client-Id` 改由 DB 管理(Tenant 設定) +- 可透過管理 UI 設定,或由 Send Engine 呼叫 `POST /integrations/send-engine/webhook-clients/upsert` 自動回填 + +測試旗標(TEST-ONLY): +- `Testing__DisableSubscriptionDryRunNoDb=true` + - 作用:`POST /subscriptions/disable` 完全不做 DB read/write,只寫 log(含預計執行的 DB 動作) + - 用途:SES/Send Engine 串接初次測試,避免測試資料污染 + - 測試結束請改回 `false` ### 1) `installer init` 用途:首次安裝(含 migrations + seed + superuser) diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 8e1846e..d70b999 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -49,7 +49,8 @@ - `X-Client-Id` - `X-Client-Id` 來源: - 由 Send Engine 的 `auth_clients.id`(UUID)提供 - - Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 設定每個租戶的對應值 + - Member Center 以 DB 設定(tenant 設定欄位)保存每個租戶的對應值 + - 可由管理 UI(Tenant 編輯)或整合 API `POST /integrations/send-engine/webhook-clients/upsert` 更新 - 簽章建議: - `HMAC-SHA256(secret, "{raw_body}")`(對齊 Send Engine 驗證器) - 驗證規則: @@ -83,6 +84,7 @@ - `POST /webhooks/subscriptions`:已實作(Member Center 發送;Send Engine 接收) - `POST /webhooks/lists/full-sync`:規格已定義(Member Center 發送;Send Engine 接收) - `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單) +- `POST /integrations/send-engine/webhook-clients/upsert`:已實作(Send Engine 回填 tenant webhook client id) ### Auth / Scope - `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id` diff --git a/docs/UI.md b/docs/UI.md index edacea9..effe1cc 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -48,6 +48,7 @@ ### 管理者端(統一 UI) - UC-11 租戶管理: `/admin/tenants` +- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`(UUID) - UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant) - UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 894ffa4..48d532f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -463,6 +463,29 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /integrations/send-engine/webhook-clients/upsert: + post: + summary: Upsert Send Engine webhook client mapping for tenant + security: [{ BearerAuth: [] }] + description: Requires scope `newsletter:events.write.global`. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertSendEngineWebhookClientRequest' + responses: + '200': + description: Updated + '400': + description: Bad request + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: Tenant not found + /webhooks/subscriptions: post: summary: (NOTE) Member Center -> Send Engine subscription events webhook @@ -898,6 +921,17 @@ components: name: { type: string } domains: { type: array, items: { type: string } } status: { type: string } + send_engine_webhook_client_id: + type: string + format: uuid + nullable: true + + UpsertSendEngineWebhookClientRequest: + type: object + required: [tenant_id, webhook_client_id] + properties: + tenant_id: { type: string, format: uuid } + webhook_client_id: { type: string, format: uuid } NewsletterList: type: object diff --git a/src/MemberCenter.Api/Contracts/AdminRequests.cs b/src/MemberCenter.Api/Contracts/AdminRequests.cs index 6c55cfe..cbc77b3 100644 --- a/src/MemberCenter.Api/Contracts/AdminRequests.cs +++ b/src/MemberCenter.Api/Contracts/AdminRequests.cs @@ -1,6 +1,6 @@ namespace MemberCenter.Api.Contracts; -public sealed record TenantRequest(string Name, List Domains, string Status); +public sealed record TenantRequest(string Name, List Domains, string Status, Guid? SendEngineWebhookClientId = null); public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status); diff --git a/src/MemberCenter.Api/Contracts/IntegrationRequests.cs b/src/MemberCenter.Api/Contracts/IntegrationRequests.cs new file mode 100644 index 0000000..1e44b78 --- /dev/null +++ b/src/MemberCenter.Api/Contracts/IntegrationRequests.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace MemberCenter.Api.Contracts; + +public sealed record UpsertSendEngineWebhookClientRequest( + [property: JsonPropertyName("tenant_id")] Guid TenantId, + [property: JsonPropertyName("webhook_client_id")] Guid WebhookClientId); diff --git a/src/MemberCenter.Api/Controllers/AdminTenantsController.cs b/src/MemberCenter.Api/Controllers/AdminTenantsController.cs index aa35479..b51093c 100644 --- a/src/MemberCenter.Api/Controllers/AdminTenantsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminTenantsController.cs @@ -27,7 +27,7 @@ public class AdminTenantsController : ControllerBase [HttpPost] public async Task Create([FromBody] TenantRequest request) { - var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status); + var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId); return Created($"/admin/tenants/{tenant.Id}", tenant); } @@ -46,7 +46,7 @@ public class AdminTenantsController : ControllerBase [HttpPut("{id:guid}")] public async Task Update(Guid id, [FromBody] TenantRequest request) { - var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status); + var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId); if (tenant is null) { return NotFound(); diff --git a/src/MemberCenter.Api/Controllers/SendEngineIntegrationController.cs b/src/MemberCenter.Api/Controllers/SendEngineIntegrationController.cs new file mode 100644 index 0000000..cb01abb --- /dev/null +++ b/src/MemberCenter.Api/Controllers/SendEngineIntegrationController.cs @@ -0,0 +1,54 @@ +using MemberCenter.Api.Contracts; +using MemberCenter.Application.Abstractions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; + +namespace MemberCenter.Api.Controllers; + +[ApiController] +[Route("integrations/send-engine")] +public class SendEngineIntegrationController : ControllerBase +{ + private readonly ITenantService _tenantService; + + public SendEngineIntegrationController(ITenantService tenantService) + { + _tenantService = tenantService; + } + + [Authorize] + [HttpPost("webhook-clients/upsert")] + public async Task UpsertWebhookClient([FromBody] UpsertSendEngineWebhookClientRequest request) + { + if (!HasScope(User, "newsletter:events.write.global")) + { + return Forbid(); + } + + if (request.TenantId == Guid.Empty || request.WebhookClientId == Guid.Empty) + { + return BadRequest("tenant_id and webhook_client_id are required."); + } + + var updated = await _tenantService.SetSendEngineWebhookClientAsync(request.TenantId, request.WebhookClientId); + if (!updated) + { + return NotFound("Tenant not found."); + } + + return Ok(new + { + tenant_id = request.TenantId, + webhook_client_id = request.WebhookClientId, + status = "updated" + }); + } + + 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); + } +} diff --git a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs index cc0b7f4..7de4892 100644 --- a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs +++ b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs @@ -4,6 +4,8 @@ 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; @@ -20,15 +22,24 @@ public class SubscriptionsController : ControllerBase "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) + MemberCenterDbContext dbContext, + IConfiguration configuration, + ILogger logger) { _emailBlacklistService = emailBlacklistService; _dbContext = dbContext; + _configuration = configuration; + _logger = logger; } [Authorize] @@ -56,6 +67,34 @@ public class SubscriptionsController : ControllerBase return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression."); } + // 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 + }); + } + if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId) { return BadRequest("tenant_id does not match token tenant scope."); diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 77bb353..c37376c 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; +using OpenIddict.Validation.AspNetCore; EnvLoader.LoadDotEnvIfDevelopment(); @@ -36,8 +37,9 @@ builder.Services builder.Services.AddAuthentication(options => { - options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme; - options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme; + options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; }); builder.Services.AddOpenIddict() @@ -72,11 +74,17 @@ builder.Services.AddOpenIddict() options.AddDevelopmentEncryptionCertificate(); options.AddDevelopmentSigningCertificate(); - options.UseAspNetCore() + var aspNetCore = options.UseAspNetCore() .EnableAuthorizationEndpointPassthrough() .EnableTokenEndpointPassthrough() .EnableLogoutEndpointPassthrough() .EnableStatusCodePagesIntegration(); + + if (builder.Environment.IsDevelopment()) + { + // TEST/LOCAL ONLY: allow HTTP for local Docker integration testing. + aspNetCore.DisableTransportSecurityRequirement(); + } }) .AddValidation(options => { diff --git a/src/MemberCenter.Application/Abstractions/ITenantService.cs b/src/MemberCenter.Application/Abstractions/ITenantService.cs index 642918c..f09c55b 100644 --- a/src/MemberCenter.Application/Abstractions/ITenantService.cs +++ b/src/MemberCenter.Application/Abstractions/ITenantService.cs @@ -6,7 +6,8 @@ public interface ITenantService { Task> ListAsync(); Task GetAsync(Guid id); - Task CreateAsync(string name, List domains, string status); - Task UpdateAsync(Guid id, string name, List domains, string status); + Task CreateAsync(string name, List domains, string status, Guid? sendEngineWebhookClientId = null); + Task UpdateAsync(Guid id, string name, List domains, string status, Guid? sendEngineWebhookClientId = null); + Task SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId); Task DeleteAsync(Guid id); } diff --git a/src/MemberCenter.Application/Models/Admin/TenantDto.cs b/src/MemberCenter.Application/Models/Admin/TenantDto.cs index 8f3d0f9..035c48f 100644 --- a/src/MemberCenter.Application/Models/Admin/TenantDto.cs +++ b/src/MemberCenter.Application/Models/Admin/TenantDto.cs @@ -1,3 +1,8 @@ namespace MemberCenter.Application.Models.Admin; -public sealed record TenantDto(Guid Id, string Name, List Domains, string Status); +public sealed record TenantDto( + Guid Id, + string Name, + List Domains, + string Status, + Guid? SendEngineWebhookClientId); diff --git a/src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs b/src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs index 8e521d1..4af204a 100644 --- a/src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs +++ b/src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs @@ -5,5 +5,4 @@ public sealed class SendEngineWebhookOptions public string? BaseUrl { get; set; } public string SubscriptionEventsPath { get; set; } = "/webhooks/subscriptions"; public string? WebhookSecret { get; set; } - public Dictionary TenantWebhookClientIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); } diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs index db555b1..304822b 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs @@ -37,14 +37,19 @@ public sealed class NewsletterService : INewsletterService public async Task SubscribeAsync(Guid listId, string email, Dictionary? preferences) { + var normalizedEmail = email.Trim().ToLowerInvariant(); + _logger.LogInformation("Newsletter subscribe requested. list_id={ListId}, email={Email}", listId, normalizedEmail); + if (await _emailBlacklist.IsBlacklistedAsync(email)) { + _logger.LogWarning("Newsletter subscribe blocked by blacklist. list_id={ListId}, email={Email}", listId, normalizedEmail); return null; } var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId); if (list is null) { + _logger.LogWarning("Newsletter subscribe failed: list not found. list_id={ListId}, email={Email}", listId, normalizedEmail); return null; } @@ -85,11 +90,19 @@ public sealed class NewsletterService : INewsletterService await _dbContext.SaveChangesAsync(); + _logger.LogInformation( + "Newsletter subscribe pending token issued. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}", + subscription.Id, + subscription.ListId, + normalizedEmail, + subscription.Status); + return new PendingSubscriptionResult(MapSubscription(subscription), confirmToken); } public async Task ConfirmAsync(string token) { + _logger.LogInformation("Newsletter confirm requested."); var confirmToken = await FindTokenAsync(token, ConfirmTokenPurpose); if (confirmToken is null && token.Contains(' ')) { @@ -99,16 +112,27 @@ public sealed class NewsletterService : INewsletterService if (confirmToken?.Subscription is null) { + _logger.LogWarning("Newsletter confirm failed: token not found or already consumed."); return null; } if (await _emailBlacklist.IsBlacklistedAsync(confirmToken.Subscription.Email)) { + _logger.LogWarning( + "Newsletter confirm blocked by blacklist. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}", + confirmToken.Subscription.Id, + confirmToken.Subscription.ListId, + confirmToken.Subscription.Email); return null; } if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow) { + _logger.LogWarning( + "Newsletter confirm failed: token expired. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}", + confirmToken.Subscription.Id, + confirmToken.Subscription.ListId, + confirmToken.Subscription.Email); return null; } @@ -117,12 +141,19 @@ public sealed class NewsletterService : INewsletterService await _dbContext.SaveChangesAsync(); var confirmed = MapSubscription(confirmToken.Subscription); + _logger.LogInformation( + "Newsletter confirm succeeded. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}", + confirmed.Id, + confirmed.ListId, + confirmed.Email, + confirmed.Status); await PublishSubscriptionEventSafeAsync("subscription.activated", confirmed); return confirmed; } public async Task UnsubscribeAsync(string token) { + _logger.LogInformation("Newsletter unsubscribe requested."); var tokenHash = HashToken(token, UnsubscribeTokenPurpose); var unsubscribeToken = await _dbContext.UnsubscribeTokens .Include(t => t.Subscription) @@ -130,11 +161,17 @@ public sealed class NewsletterService : INewsletterService if (unsubscribeToken?.Subscription is null) { + _logger.LogWarning("Newsletter unsubscribe failed: token not found or already consumed."); return null; } if (await _emailBlacklist.IsBlacklistedAsync(unsubscribeToken.Subscription.Email)) { + _logger.LogWarning( + "Newsletter unsubscribe blocked by blacklist. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}", + unsubscribeToken.Subscription.Id, + unsubscribeToken.Subscription.ListId, + unsubscribeToken.Subscription.Email); return null; } @@ -143,14 +180,24 @@ public sealed class NewsletterService : INewsletterService await _dbContext.SaveChangesAsync(); var unsubscribed = MapSubscription(unsubscribeToken.Subscription); + _logger.LogInformation( + "Newsletter unsubscribe succeeded. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}", + unsubscribed.Id, + unsubscribed.ListId, + unsubscribed.Email, + unsubscribed.Status); await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", unsubscribed); return unsubscribed; } public async Task IssueUnsubscribeTokenAsync(Guid listId, string email) { + var normalizedEmail = email.Trim().ToLowerInvariant(); + _logger.LogInformation("Newsletter unsubscribe-token requested. list_id={ListId}, email={Email}", listId, normalizedEmail); + if (await _emailBlacklist.IsBlacklistedAsync(email)) { + _logger.LogWarning("Newsletter unsubscribe-token blocked by blacklist. list_id={ListId}, email={Email}", listId, normalizedEmail); return null; } @@ -161,6 +208,7 @@ public sealed class NewsletterService : INewsletterService if (subscription is null) { + _logger.LogWarning("Newsletter unsubscribe-token failed: subscription not found. list_id={ListId}, email={Email}", listId, normalizedEmail); return null; } @@ -174,6 +222,11 @@ public sealed class NewsletterService : INewsletterService }); await _dbContext.SaveChangesAsync(); + _logger.LogInformation( + "Newsletter unsubscribe-token issued. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}", + subscription.Id, + subscription.ListId, + normalizedEmail); return token; } diff --git a/src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs b/src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs index 58f0eab..0ff7d24 100644 --- a/src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs +++ b/src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs @@ -4,24 +4,31 @@ using System.Text.Json; using MemberCenter.Application.Abstractions; using MemberCenter.Application.Models.Newsletter; using MemberCenter.Infrastructure.Configuration; +using MemberCenter.Infrastructure.Persistence; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.EntityFrameworkCore; namespace MemberCenter.Infrastructure.Services; public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher { + private const string SendEngineWebhookClientKeyPrefix = "send_engine:webhook_client:"; + private readonly HttpClient _httpClient; private readonly IOptions _options; + private readonly MemberCenterDbContext _dbContext; private readonly ILogger _logger; public SendEngineWebhookPublisher( HttpClient httpClient, IOptions options, + MemberCenterDbContext dbContext, ILogger logger) { _httpClient = httpClient; _options = options; + _dbContext = dbContext; _logger = logger; } @@ -38,7 +45,7 @@ public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher return; } - var clientId = ResolveWebhookClientId(options, tenantId); + var clientId = await ResolveWebhookClientIdAsync(tenantId, cancellationToken); if (string.IsNullOrWhiteSpace(clientId)) { _logger.LogWarning("Skip webhook publish: missing webhook client id for tenant {TenantId}", tenantId); @@ -105,15 +112,18 @@ public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher return normalizedBase + normalizedPath; } - private static string? ResolveWebhookClientId(SendEngineWebhookOptions options, Guid tenantId) + private async Task ResolveWebhookClientIdAsync(Guid tenantId, CancellationToken cancellationToken) { - var tenantKey = tenantId.ToString(); - if (options.TenantWebhookClientIds.TryGetValue(tenantKey, out var tenantClientId) - && !string.IsNullOrWhiteSpace(tenantClientId)) + var key = $"{SendEngineWebhookClientKeyPrefix}{tenantId:D}"; + var flag = await _dbContext.SystemFlags + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Key == key, cancellationToken); + if (flag is null || string.IsNullOrWhiteSpace(flag.Value)) { - return tenantClientId; + return null; } - return null; + + return flag.Value.Trim(); } private static string ComputeHmacHex(string secret, string payload) diff --git a/src/MemberCenter.Infrastructure/Services/TenantService.cs b/src/MemberCenter.Infrastructure/Services/TenantService.cs index d3c7eb5..44ef4bb 100644 --- a/src/MemberCenter.Infrastructure/Services/TenantService.cs +++ b/src/MemberCenter.Infrastructure/Services/TenantService.cs @@ -8,6 +8,8 @@ namespace MemberCenter.Infrastructure.Services; public sealed class TenantService : ITenantService { + private const string SendEngineWebhookClientKeyPrefix = "send_engine:webhook_client:"; + private readonly MemberCenterDbContext _dbContext; public TenantService(MemberCenterDbContext dbContext) @@ -18,16 +20,23 @@ public sealed class TenantService : ITenantService public async Task> ListAsync() { var tenants = await _dbContext.Tenants.ToListAsync(); - return tenants.Select(MapTenant).ToList(); + var mappings = await LoadWebhookClientMappingsAsync(tenants.Select(t => t.Id)); + return tenants.Select(t => MapTenant(t, mappings.TryGetValue(t.Id, out var clientId) ? clientId : null)).ToList(); } public async Task GetAsync(Guid id) { var tenant = await _dbContext.Tenants.FindAsync(id); - return tenant is null ? null : MapTenant(tenant); + if (tenant is null) + { + return null; + } + + var mapping = await LoadWebhookClientMappingAsync(id); + return MapTenant(tenant, mapping); } - public async Task CreateAsync(string name, List domains, string status) + public async Task CreateAsync(string name, List domains, string status, Guid? sendEngineWebhookClientId = null) { var tenant = new Tenant { @@ -39,10 +48,11 @@ public sealed class TenantService : ITenantService _dbContext.Tenants.Add(tenant); await _dbContext.SaveChangesAsync(); - return MapTenant(tenant); + await SetWebhookClientMappingInternalAsync(tenant.Id, sendEngineWebhookClientId); + return MapTenant(tenant, sendEngineWebhookClientId); } - public async Task UpdateAsync(Guid id, string name, List domains, string status) + public async Task UpdateAsync(Guid id, string name, List domains, string status, Guid? sendEngineWebhookClientId = null) { var tenant = await _dbContext.Tenants.FindAsync(id); if (tenant is null) @@ -54,7 +64,20 @@ public sealed class TenantService : ITenantService tenant.Domains = domains; tenant.Status = status; await _dbContext.SaveChangesAsync(); - return MapTenant(tenant); + await SetWebhookClientMappingInternalAsync(id, sendEngineWebhookClientId); + return MapTenant(tenant, sendEngineWebhookClientId); + } + + public async Task SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId) + { + var exists = await _dbContext.Tenants.AnyAsync(t => t.Id == tenantId); + if (!exists) + { + return false; + } + + await SetWebhookClientMappingInternalAsync(tenantId, webhookClientId); + return true; } public async Task DeleteAsync(Guid id) @@ -65,13 +88,104 @@ public sealed class TenantService : ITenantService return false; } + var mapping = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == BuildWebhookClientKey(id)); + if (mapping is not null) + { + _dbContext.SystemFlags.Remove(mapping); + } + _dbContext.Tenants.Remove(tenant); await _dbContext.SaveChangesAsync(); return true; } - private static TenantDto MapTenant(Tenant tenant) + private static TenantDto MapTenant(Tenant tenant, Guid? webhookClientId) { - return new TenantDto(tenant.Id, tenant.Name, tenant.Domains, tenant.Status); + return new TenantDto(tenant.Id, tenant.Name, tenant.Domains, tenant.Status, webhookClientId); + } + + private async Task> LoadWebhookClientMappingsAsync(IEnumerable tenantIds) + { + var keys = tenantIds.Select(BuildWebhookClientKey).ToHashSet(StringComparer.Ordinal); + if (keys.Count == 0) + { + return new Dictionary(); + } + + var flags = await _dbContext.SystemFlags + .Where(f => keys.Contains(f.Key)) + .ToListAsync(); + + var result = new Dictionary(); + foreach (var flag in flags) + { + var tenantPart = flag.Key.Replace(SendEngineWebhookClientKeyPrefix, string.Empty, StringComparison.Ordinal); + if (!Guid.TryParse(tenantPart, out var tenantId)) + { + continue; + } + + if (!Guid.TryParse(flag.Value, out var webhookClientId)) + { + continue; + } + + result[tenantId] = webhookClientId; + } + + return result; + } + + private async Task LoadWebhookClientMappingAsync(Guid tenantId) + { + var key = BuildWebhookClientKey(tenantId); + var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == key); + if (flag is null || !Guid.TryParse(flag.Value, out var webhookClientId)) + { + return null; + } + + return webhookClientId; + } + + private async Task SetWebhookClientMappingInternalAsync(Guid tenantId, Guid? webhookClientId) + { + var key = BuildWebhookClientKey(tenantId); + var existing = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == key); + + if (!webhookClientId.HasValue) + { + if (existing is not null) + { + _dbContext.SystemFlags.Remove(existing); + await _dbContext.SaveChangesAsync(); + } + + return; + } + + var value = webhookClientId.Value.ToString(); + if (existing is null) + { + _dbContext.SystemFlags.Add(new Domain.Entities.SystemFlag + { + Id = Guid.NewGuid(), + Key = key, + Value = value, + UpdatedAt = DateTimeOffset.UtcNow + }); + } + else + { + existing.Value = value; + existing.UpdatedAt = DateTimeOffset.UtcNow; + } + + await _dbContext.SaveChangesAsync(); + } + + private static string BuildWebhookClientKey(Guid tenantId) + { + return $"{SendEngineWebhookClientKeyPrefix}{tenantId:D}"; } } diff --git a/src/MemberCenter.Web/Controllers/Admin/TenantsController.cs b/src/MemberCenter.Web/Controllers/Admin/TenantsController.cs index 934f421..0647ccd 100644 --- a/src/MemberCenter.Web/Controllers/Admin/TenantsController.cs +++ b/src/MemberCenter.Web/Controllers/Admin/TenantsController.cs @@ -37,8 +37,14 @@ public class TenantsController : Controller return View(model); } + if (!TryParseWebhookClientId(model.SendEngineWebhookClientId, out var webhookClientId)) + { + ModelState.AddModelError(nameof(model.SendEngineWebhookClientId), "Webhook client id must be a valid UUID."); + return View(model); + } + var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); - await _tenantService.CreateAsync(model.Name, domains, model.Status); + await _tenantService.CreateAsync(model.Name, domains, model.Status, webhookClientId); return RedirectToAction("Index"); } @@ -56,7 +62,8 @@ public class TenantsController : Controller Id = tenant.Id, Name = tenant.Name, Domains = string.Join(",", tenant.Domains), - Status = tenant.Status + Status = tenant.Status, + SendEngineWebhookClientId = tenant.SendEngineWebhookClientId?.ToString() }); } @@ -68,8 +75,14 @@ public class TenantsController : Controller return View(model); } + if (!TryParseWebhookClientId(model.SendEngineWebhookClientId, out var webhookClientId)) + { + ModelState.AddModelError(nameof(model.SendEngineWebhookClientId), "Webhook client id must be a valid UUID."); + return View(model); + } + var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); - var updated = await _tenantService.UpdateAsync(id, model.Name, domains, model.Status); + var updated = await _tenantService.UpdateAsync(id, model.Name, domains, model.Status, webhookClientId); if (updated is null) { return NotFound(); @@ -84,4 +97,21 @@ public class TenantsController : Controller await _tenantService.DeleteAsync(id); return RedirectToAction("Index"); } + + private static bool TryParseWebhookClientId(string? value, out Guid? webhookClientId) + { + webhookClientId = null; + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + if (!Guid.TryParse(value.Trim(), out var parsed)) + { + return false; + } + + webhookClientId = parsed; + return true; + } } diff --git a/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs index 91dc5cb..8f196e3 100644 --- a/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs @@ -13,4 +13,6 @@ public sealed class TenantFormViewModel [Required] public string Status { get; set; } = "active"; + + public string? SendEngineWebhookClientId { get; set; } } diff --git a/src/MemberCenter.Web/Views/Admin/Tenants/Create.cshtml b/src/MemberCenter.Web/Views/Admin/Tenants/Create.cshtml index 87a86b2..d242ba8 100644 --- a/src/MemberCenter.Web/Views/Admin/Tenants/Create.cshtml +++ b/src/MemberCenter.Web/Views/Admin/Tenants/Create.cshtml @@ -13,5 +13,9 @@ + + + + diff --git a/src/MemberCenter.Web/Views/Admin/Tenants/Edit.cshtml b/src/MemberCenter.Web/Views/Admin/Tenants/Edit.cshtml index b318aab..8a6129f 100644 --- a/src/MemberCenter.Web/Views/Admin/Tenants/Edit.cshtml +++ b/src/MemberCenter.Web/Views/Admin/Tenants/Edit.cshtml @@ -13,5 +13,9 @@ + + + + diff --git a/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml b/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml index 4b67a83..87747fe 100644 --- a/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml +++ b/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml @@ -4,7 +4,7 @@

Create

- + @foreach (var tenant in Model) @@ -12,6 +12,7 @@ +
NameTenant IdDomainsStatus
NameTenant IdWebhook Client IdDomainsStatus
@tenant.Name @tenant.Id@(tenant.SendEngineWebhookClientId?.ToString() ?? "-") @string.Join(",", tenant.Domains) @tenant.Status