feat: Implement Send Engine webhook client management and update related API documentation

This commit is contained in:
warrenchen 2026-02-19 17:20:17 +09:00
parent 035a7ca821
commit 5c7d4cdf5a
24 changed files with 415 additions and 34 deletions

View File

@ -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

View File

@ -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

View File

@ -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] 會員中心提供個人資料頁

View File

@ -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

View File

@ -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 設定欄位)保存每個租戶的對應值
- 可由管理 UITenant 編輯)或整合 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`

View File

@ -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`

View File

@ -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

View File

@ -1,6 +1,6 @@
namespace MemberCenter.Api.Contracts;
public sealed record TenantRequest(string Name, List<string> Domains, string Status);
public sealed record TenantRequest(string Name, List<string> Domains, string Status, Guid? SendEngineWebhookClientId = null);
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);

View File

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

View File

@ -27,7 +27,7 @@ public class AdminTenantsController : ControllerBase
[HttpPost]
public async Task<IActionResult> 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<IActionResult> 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();

View File

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

View File

@ -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<SubscriptionsController> _logger;
public SubscriptionsController(
IEmailBlacklistService emailBlacklistService,
MemberCenterDbContext dbContext)
MemberCenterDbContext dbContext,
IConfiguration configuration,
ILogger<SubscriptionsController> 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<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
});
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
{
return BadRequest("tenant_id does not match token tenant scope.");

View File

@ -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 =>
{

View File

@ -6,7 +6,8 @@ public interface ITenantService
{
Task<IReadOnlyList<TenantDto>> ListAsync();
Task<TenantDto?> GetAsync(Guid id);
Task<TenantDto> CreateAsync(string name, List<string> domains, string status);
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status);
Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId);
Task<bool> DeleteAsync(Guid id);
}

View File

@ -1,3 +1,8 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record TenantDto(Guid Id, string Name, List<string> Domains, string Status);
public sealed record TenantDto(
Guid Id,
string Name,
List<string> Domains,
string Status,
Guid? SendEngineWebhookClientId);

View File

@ -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<string, string> TenantWebhookClientIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@ -37,14 +37,19 @@ public sealed class NewsletterService : INewsletterService
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? 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<SubscriptionDto?> 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<SubscriptionDto?> 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<string?> 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;
}

View File

@ -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<SendEngineWebhookOptions> _options;
private readonly MemberCenterDbContext _dbContext;
private readonly ILogger<SendEngineWebhookPublisher> _logger;
public SendEngineWebhookPublisher(
HttpClient httpClient,
IOptions<SendEngineWebhookOptions> options,
MemberCenterDbContext dbContext,
ILogger<SendEngineWebhookPublisher> 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<string?> 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)

View File

@ -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<IReadOnlyList<TenantDto>> 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<TenantDto?> 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<TenantDto> CreateAsync(string name, List<string> domains, string status)
public async Task<TenantDto> CreateAsync(string name, List<string> 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<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status)
public async Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> 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<bool> 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<bool> 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<Dictionary<Guid, Guid>> LoadWebhookClientMappingsAsync(IEnumerable<Guid> tenantIds)
{
var keys = tenantIds.Select(BuildWebhookClientKey).ToHashSet(StringComparer.Ordinal);
if (keys.Count == 0)
{
return new Dictionary<Guid, Guid>();
}
var flags = await _dbContext.SystemFlags
.Where(f => keys.Contains(f.Key))
.ToListAsync();
var result = new Dictionary<Guid, Guid>();
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<Guid?> 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}";
}
}

View File

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

View File

@ -13,4 +13,6 @@ public sealed class TenantFormViewModel
[Required]
public string Status { get; set; } = "active";
public string? SendEngineWebhookClientId { get; set; }
}

View File

@ -13,5 +13,9 @@
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<label>Send Engine Webhook Client Id (UUID)</label>
<input asp-for="SendEngineWebhookClientId" />
<span asp-validation-for="SendEngineWebhookClientId"></span>
<button type="submit">Save</button>
</form>

View File

@ -13,5 +13,9 @@
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<label>Send Engine Webhook Client Id (UUID)</label>
<input asp-for="SendEngineWebhookClientId" />
<span asp-validation-for="SendEngineWebhookClientId"></span>
<button type="submit">Save</button>
</form>

View File

@ -4,7 +4,7 @@
<p><a href="/admin/tenants/create">Create</a></p>
<table>
<thead>
<tr><th>Name</th><th>Tenant Id</th><th>Domains</th><th>Status</th><th></th></tr>
<tr><th>Name</th><th>Tenant Id</th><th>Webhook Client Id</th><th>Domains</th><th>Status</th><th></th></tr>
</thead>
<tbody>
@foreach (var tenant in Model)
@ -12,6 +12,7 @@
<tr>
<td>@tenant.Name</td>
<td><code>@tenant.Id</code></td>
<td><code>@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")</code></td>
<td>@string.Join(",", tenant.Domains)</td>
<td>@tenant.Status</td>
<td>