Implement unsubscribe token feature and enhance preferences management with list_id and email validation

This commit is contained in:
warrenchen 2026-02-04 16:28:32 +09:00
parent f84cfb5beb
commit e4af8f067f
26 changed files with 329 additions and 70 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
.git
.gitignore
.vscode
.idea
.dotnet
.nuget
**/bin
**/obj
**/*.user
**/*.suo
**/*.swp
**/*.log
.env
.env.*
!/.env.example

View File

@ -33,16 +33,32 @@
- 金流/付費會員
## 文件
- `docs/DESIGN.md`
- `docs/UI.md`
- `docs/USE_CASES.md`
- `docs/FLOWS.md`
- `docs/OPENAPI.md`
- `docs/openapi.yaml`
- `docs/SCHEMA.sql`
- `docs/TECH_STACK.md`
- `docs/SEED.sql`
- `docs/INSTALL.md`
- `docs/DESIGN.md`:系統設計總覽、核心模組、資料模型與流程原則
- `docs/UI.md`UI 路由規劃與 Use Cases 對應
- `docs/USE_CASES.md`:需求用例(角色、情境、驗收)
- `docs/FLOWS.md`:主要業務流程(註冊、登入、訂閱、退訂、管理)
- `docs/OPENAPI.md`API 規格說明與補充規則
- `docs/openapi.yaml`OpenAPI 3.1 正式規格檔
- `docs/SCHEMA.sql`:資料庫 schemaPostgreSQL
- `docs/SEED.sql`:初始化/測試資料
- `docs/TECH_STACK.md`:技術棧與選型
- `docs/INSTALL.md`:安裝、初始化與維運指令
## 專案結構
```text
member_center/
├── src/
│ ├── MemberCenter.Api/ # REST APIOAuth/OIDC、訂閱、管理 API
│ ├── MemberCenter.Web/ # MVC Web UI會員與後台頁面
│ ├── MemberCenter.Installer/ # 安裝與初始化 CLImigrate/init/admin
│ ├── MemberCenter.Application/ # 應用層介面與 DTO
│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作
│ └── MemberCenter.Domain/ # 領域實體與常數
├── docs/ # 專案文件
├── .vscode/ # 本機開發啟動與工作設定
├── MemberCenter.sln # Solution
└── README.md
```
## 待確認事項
- OAuth2 scopes 與最小 claimsemail/profile

View File

@ -15,6 +15,7 @@
- Double Opt-in
- 各站自行設計 UI主要走 API少數狀況使用 redirect
- 多租戶為邏輯隔離,但會員資料跨站共享
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
- 訂閱狀態同步採 event/queue
- PostgreSQL
- 實作C# .NET Core + MVC + OpenIddict
@ -108,6 +109,7 @@
- POST `/newsletter/subscribe`
- GET `/newsletter/confirm`
- POST `/newsletter/unsubscribe`
- POST `/newsletter/unsubscribe-token`
- GET `/newsletter/preferences`
- POST `/newsletter/preferences`

View File

@ -26,13 +26,14 @@
- [UI] 訂閱改為 active發出 event `subscription.activated`
## F-05 取消訂閱(單一清單)
- [API] 站點以 `list_id + email` 呼叫 `POST /newsletter/unsubscribe-token` 取得 token
- [UI] 使用者點擊退訂連結 `/newsletter/unsubscribe?token=...`
- [UI] 訂閱狀態改為 unsubscribed
- [API] 發出 event `subscription.unsubscribed`
## F-06 訂閱偏好管理(登入後)
- [API] 站點讀取 `/newsletter/preferences`
- [API] 站點更新 `/newsletter/preferences`
- [API] 站點`list_id + email` 讀取 `/newsletter/preferences`
- [API] 站點`list_id + email` 更新 `/newsletter/preferences`
- [UI] 會員中心提供偏好頁(可選)
## F-07 會員資料查看

View File

@ -21,3 +21,9 @@
- `/oauth/token``/auth/login``/auth/refresh` 使用 `application/x-www-form-urlencoded`
- `/auth/email/verify` 需要 `token` + `email`
- `/newsletter/subscribe` 會回傳 `confirm_token`
- `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token`
- `/newsletter/preferences`GET/POST需要 `list_id + email`,避免跨租戶資料讀取/更新
## 多租戶資料隔離原則
- 與訂閱者資料preferences、unsubscribe token相關的查詢與寫入一律必須帶 `list_id + email` 做租戶邊界約束。
- 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。

View File

@ -43,7 +43,7 @@
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
- UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...`
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
- UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?subscriptionId=...`
- UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?list_id=...&email=...`
- UC-10 會員資料查看: `/profile`
### 管理者端(統一 UI

View File

@ -267,17 +267,41 @@ paths:
schema:
$ref: '#/components/schemas/Subscription'
/newsletter/unsubscribe-token:
post:
summary: Issue unsubscribe token
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [list_id, email]
properties:
list_id: { type: string }
email: { type: string, format: email }
responses:
'200':
description: Unsubscribe token issued
content:
application/json:
schema:
type: object
properties:
unsubscribe_token: { type: string }
/newsletter/preferences:
get:
summary: Get preferences
parameters:
- in: query
name: subscription_id
required: false
name: list_id
required: true
schema: { type: string }
- in: query
name: email
required: false
required: true
schema: { type: string, format: email }
responses:
'200':
@ -295,9 +319,10 @@ paths:
application/json:
schema:
type: object
required: [subscription_id, preferences]
required: [list_id, email, preferences]
properties:
subscription_id: { type: string }
list_id: { type: string }
email: { type: string, format: email }
preferences: { type: object }
responses:
'200':

View File

@ -1,7 +1,21 @@
using System.Text.Json.Serialization;
namespace MemberCenter.Api.Contracts;
public sealed record SubscribeRequest(Guid ListId, string Email, Dictionary<string, object>? Preferences, string? Source);
public sealed record SubscribeRequest(
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("preferences")] Dictionary<string, object>? Preferences,
[property: JsonPropertyName("source")] string? Source);
public sealed record UnsubscribeRequest(string Token);
public sealed record UnsubscribeRequest(
[property: JsonPropertyName("token")] string Token);
public sealed record UpdatePreferencesRequest(Guid SubscriptionId, Dictionary<string, object> Preferences);
public sealed record IssueUnsubscribeTokenRequest(
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("email")] string Email);
public sealed record UpdatePreferencesRequest(
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("preferences")] Dictionary<string, object> Preferences);

View File

@ -73,10 +73,35 @@ public class NewsletterController : ControllerBase
});
}
[HttpGet("preferences")]
public async Task<IActionResult> Preferences([FromQuery] Guid? subscriptionId, [FromQuery] string? email)
[HttpPost("unsubscribe-token")]
public async Task<IActionResult> IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request)
{
var subscription = await _newsletterService.GetPreferencesAsync(subscriptionId, email);
if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
{
return BadRequest("Both list_id and email are required.");
}
var token = await _newsletterService.IssueUnsubscribeTokenAsync(request.ListId, request.Email);
if (token is null)
{
return NotFound("Subscription not found.");
}
return Ok(new
{
unsubscribe_token = token
});
}
[HttpGet("preferences")]
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email)
{
if (!listId.HasValue || listId.Value == Guid.Empty || string.IsNullOrWhiteSpace(email))
{
return BadRequest("Both list_id and email are required.");
}
var subscription = await _newsletterService.GetPreferencesAsync(listId.Value, email);
if (subscription is null)
{
return NotFound("Subscription not found.");
@ -95,7 +120,12 @@ public class NewsletterController : ControllerBase
[HttpPost("preferences")]
public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest request)
{
var subscription = await _newsletterService.UpdatePreferencesAsync(request.SubscriptionId, request.Preferences);
if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
{
return BadRequest("Both list_id and email are required.");
}
var subscription = await _newsletterService.UpdatePreferencesAsync(request.ListId, request.Email, request.Preferences);
if (subscription is null)
{
return NotFound("Subscription not found.");

View File

@ -0,0 +1,18 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
RUN dotnet restore src/MemberCenter.Api/MemberCenter.Api.csproj
RUN dotnet publish src/MemberCenter.Api/MemberCenter.Api.csproj \
-c $BUILD_CONFIGURATION \
-o /app/publish \
/p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MemberCenter.Api.dll"]

View File

@ -6,7 +6,8 @@ public interface INewsletterService
{
Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences);
Task<SubscriptionDto?> ConfirmAsync(string token);
Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email);
Task<SubscriptionDto?> UnsubscribeAsync(string token);
Task<SubscriptionDto?> GetPreferencesAsync(Guid? subscriptionId, string? email);
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid subscriptionId, Dictionary<string, object> preferences);
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
}

View File

@ -1,3 +1,3 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record NewsletterListDto(Guid Id, Guid TenantId, string Name, string Status);
public sealed record NewsletterListDto(Guid Id, Guid TenantId, string TenantName, string Name, string Status);

View File

@ -17,14 +17,18 @@ public sealed class NewsletterListService : INewsletterListService
public async Task<IReadOnlyList<NewsletterListDto>> ListAsync()
{
var lists = await _dbContext.NewsletterLists.ToListAsync();
return lists.Select(MapList).ToList();
var lists = await _dbContext.NewsletterLists
.Include(l => l.Tenant)
.ToListAsync();
return lists.Select(l => MapList(l, l.Tenant?.Name)).ToList();
}
public async Task<NewsletterListDto?> GetAsync(Guid id)
{
var list = await _dbContext.NewsletterLists.FindAsync(id);
return list is null ? null : MapList(list);
var list = await _dbContext.NewsletterLists
.Include(l => l.Tenant)
.FirstOrDefaultAsync(l => l.Id == id);
return list is null ? null : MapList(list, list.Tenant?.Name);
}
public async Task<NewsletterListDto> CreateAsync(Guid tenantId, string name, string status)
@ -39,7 +43,8 @@ public sealed class NewsletterListService : INewsletterListService
_dbContext.NewsletterLists.Add(list);
await _dbContext.SaveChangesAsync();
return MapList(list);
var tenantName = await GetTenantNameAsync(tenantId);
return MapList(list, tenantName);
}
public async Task<NewsletterListDto?> UpdateAsync(Guid id, Guid tenantId, string name, string status)
@ -54,7 +59,8 @@ public sealed class NewsletterListService : INewsletterListService
list.Name = name;
list.Status = status;
await _dbContext.SaveChangesAsync();
return MapList(list);
var tenantName = await GetTenantNameAsync(tenantId);
return MapList(list, tenantName);
}
public async Task<bool> DeleteAsync(Guid id)
@ -70,8 +76,16 @@ public sealed class NewsletterListService : INewsletterListService
return true;
}
private static NewsletterListDto MapList(NewsletterList list)
private static NewsletterListDto MapList(NewsletterList list, string? tenantName)
{
return new NewsletterListDto(list.Id, list.TenantId, list.Name, list.Status);
return new NewsletterListDto(list.Id, list.TenantId, tenantName ?? string.Empty, list.Name, list.Status);
}
private Task<string?> GetTenantNameAsync(Guid tenantId)
{
return _dbContext.Tenants
.Where(t => t.Id == tenantId)
.Select(t => t.Name)
.FirstOrDefaultAsync();
}
}

View File

@ -12,6 +12,11 @@ namespace MemberCenter.Infrastructure.Services;
public sealed class NewsletterService : INewsletterService
{
private const string ConfirmTokenPurpose = "confirm";
private const string UnsubscribeTokenPurpose = "unsubscribe";
private const int ConfirmTokenTtlDays = 7;
private const int UnsubscribeTokenTtlDays = 7;
private readonly MemberCenterDbContext _dbContext;
public NewsletterService(MemberCenterDbContext dbContext)
@ -43,8 +48,13 @@ public sealed class NewsletterService : INewsletterService
_dbContext.NewsletterSubscriptions.Add(subscription);
}
else
{
// Keep active subscriptions active; only pending/unsubscribed subscriptions need reconfirmation.
if (!string.Equals(subscription.Status, SubscriptionStatus.Active, StringComparison.OrdinalIgnoreCase))
{
subscription.Status = SubscriptionStatus.Pending;
}
subscription.Preferences = ToJsonDocument(preferences);
}
@ -53,8 +63,8 @@ public sealed class NewsletterService : INewsletterService
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(confirmToken),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
TokenHash = HashToken(confirmToken, ConfirmTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(ConfirmTokenTtlDays)
});
await _dbContext.SaveChangesAsync();
@ -64,7 +74,7 @@ public sealed class NewsletterService : INewsletterService
public async Task<SubscriptionDto?> ConfirmAsync(string token)
{
var tokenHash = HashToken(token);
var tokenHash = HashToken(token, ConfirmTokenPurpose);
var confirmToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
@ -88,7 +98,7 @@ public sealed class NewsletterService : INewsletterService
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
{
var tokenHash = HashToken(token);
var tokenHash = HashToken(token, UnsubscribeTokenPurpose);
var unsubscribeToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
@ -105,28 +115,47 @@ public sealed class NewsletterService : INewsletterService
return MapSubscription(unsubscribeToken.Subscription);
}
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid? subscriptionId, string? email)
public async Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email)
{
NewsletterSubscription? subscription = null;
if (subscriptionId.HasValue)
{
subscription = await _dbContext.NewsletterSubscriptions.FindAsync(subscriptionId.Value);
}
else if (!string.IsNullOrWhiteSpace(email))
{
subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.Email == email)
var subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.ListId == listId && s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (subscription is null)
{
return null;
}
var token = CreateToken();
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(token, UnsubscribeTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(UnsubscribeTokenTtlDays)
});
await _dbContext.SaveChangesAsync();
return token;
}
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email)
{
var subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.ListId == listId && s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
return subscription is null ? null : MapSubscription(subscription);
}
public async Task<SubscriptionDto?> UpdatePreferencesAsync(Guid subscriptionId, Dictionary<string, object> preferences)
public async Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences)
{
var subscription = await _dbContext.NewsletterSubscriptions.FindAsync(subscriptionId);
var subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.ListId == listId && s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (subscription is null)
{
return null;
@ -144,10 +173,10 @@ public sealed class NewsletterService : INewsletterService
return Convert.ToBase64String(bytes);
}
private static string HashToken(string token)
private static string HashToken(string token, string purpose)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(token));
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes($"{purpose}:{token}"));
return Convert.ToHexString(bytes).ToLowerInvariant();
}

View File

@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
RUN dotnet restore src/MemberCenter.Installer/MemberCenter.Installer.csproj
RUN dotnet publish src/MemberCenter.Installer/MemberCenter.Installer.csproj \
-c $BUILD_CONFIGURATION \
-o /app/publish \
/p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MemberCenter.Installer.dll"]

View File

@ -10,10 +10,12 @@ namespace MemberCenter.Web.Controllers.Admin;
public class NewsletterListsController : Controller
{
private readonly INewsletterListService _listService;
private readonly ITenantService _tenantService;
public NewsletterListsController(INewsletterListService listService)
public NewsletterListsController(INewsletterListService listService, ITenantService tenantService)
{
_listService = listService;
_tenantService = tenantService;
}
[HttpGet("")]
@ -24,9 +26,13 @@ public class NewsletterListsController : Controller
}
[HttpGet("create")]
public IActionResult Create()
public async Task<IActionResult> Create()
{
return View(new NewsletterListFormViewModel());
var tenants = await _tenantService.ListAsync();
return View(new NewsletterListFormViewModel
{
Tenants = tenants
});
}
[HttpPost("create")]
@ -34,6 +40,7 @@ public class NewsletterListsController : Controller
{
if (!ModelState.IsValid)
{
model.Tenants = await _tenantService.ListAsync();
return View(model);
}
@ -50,12 +57,14 @@ public class NewsletterListsController : Controller
return NotFound();
}
var tenants = await _tenantService.ListAsync();
return View(new NewsletterListFormViewModel
{
Id = list.Id,
TenantId = list.TenantId,
Name = list.Name,
Status = list.Status
Status = list.Status,
Tenants = tenants
});
}
@ -64,6 +73,7 @@ public class NewsletterListsController : Controller
{
if (!ModelState.IsValid)
{
model.Tenants = await _tenantService.ListAsync();
return View(model);
}

View File

@ -65,9 +65,21 @@ public class NewsletterController : Controller
}
[HttpGet]
public IActionResult Preferences(Guid subscriptionId)
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid listId, [FromQuery] string email)
{
return View(new PreferencesViewModel { SubscriptionId = subscriptionId });
var subscription = await _newsletterService.GetPreferencesAsync(listId, email);
if (subscription is null)
{
ViewData["Result"] = "Subscription not found.";
return View("PreferencesResult");
}
return View(new PreferencesViewModel
{
ListId = listId,
Email = email,
PreferencesJson = subscription.Preferences.GetRawText()
});
}
[HttpPost]
@ -89,7 +101,14 @@ public class NewsletterController : Controller
return View(model);
}
var subscription = await _newsletterService.UpdatePreferencesAsync(model.SubscriptionId, preferences ?? new Dictionary<string, object>());
var existing = await _newsletterService.GetPreferencesAsync(model.ListId, model.Email);
if (existing is null)
{
ViewData["Result"] = "Subscription not found.";
return View("PreferencesResult");
}
var subscription = await _newsletterService.UpdatePreferencesAsync(model.ListId, model.Email, preferences ?? new Dictionary<string, object>());
if (subscription is null)
{
ViewData["Result"] = "Subscription not found.";

View File

@ -0,0 +1,18 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
RUN dotnet restore src/MemberCenter.Web/MemberCenter.Web.csproj
RUN dotnet publish src/MemberCenter.Web/MemberCenter.Web.csproj \
-c $BUILD_CONFIGURATION \
-o /app/publish \
/p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MemberCenter.Web.dll"]

View File

@ -9,6 +9,9 @@ public sealed class NewsletterListFormViewModel
[Required]
public Guid TenantId { get; set; }
public IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto> Tenants { get; set; }
= Array.Empty<MemberCenter.Application.Models.Admin.TenantDto>();
[Required]
public string Name { get; set; } = string.Empty;

View File

@ -5,7 +5,11 @@ namespace MemberCenter.Web.Models.Newsletter;
public sealed class PreferencesViewModel
{
[Required]
public Guid SubscriptionId { get; set; }
public Guid ListId { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
public string PreferencesJson { get; set; } = "{}";
}

View File

@ -58,7 +58,12 @@ builder.Services.AddOpenIddict()
.ReplaceDefaultEntities<Guid>();
});
builder.Services.AddControllersWithViews();
builder.Services.AddControllersWithViews()
.AddRazorOptions(options =>
{
options.ViewLocationFormats.Insert(0, "/Views/Admin/{1}/{0}.cshtml");
options.ViewLocationFormats.Insert(0, "/Views/Admin/Shared/{0}.cshtml");
});
var app = builder.Build();

View File

@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5060",
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@ -3,7 +3,13 @@
<h1>Create Newsletter List</h1>
<form method="post">
<label>Tenant Id</label>
<input asp-for="TenantId" />
<select asp-for="TenantId">
<option value="">Select a tenant</option>
@foreach (var tenant in Model.Tenants)
{
<option value="@tenant.Id">@tenant.Name</option>
}
</select>
<span asp-validation-for="TenantId"></span>
<label>Name</label>

View File

@ -3,7 +3,13 @@
<h1>Edit Newsletter List</h1>
<form method="post">
<label>Tenant Id</label>
<input asp-for="TenantId" />
<select asp-for="TenantId">
<option value="">Select a tenant</option>
@foreach (var tenant in Model.Tenants)
{
<option value="@tenant.Id">@tenant.Name</option>
}
</select>
<span asp-validation-for="TenantId"></span>
<label>Name</label>

View File

@ -4,14 +4,15 @@
<p><a href="/admin/newsletter-lists/create">Create</a></p>
<table>
<thead>
<tr><th>Name</th><th>Tenant</th><th>Status</th><th></th></tr>
<tr><th>List ID</th><th>Name</th><th>Tenant</th><th>Status</th><th></th></tr>
</thead>
<tbody>
@foreach (var list in Model)
{
<tr>
<td><code>@list.Id</code></td>
<td>@list.Name</td>
<td>@list.TenantId</td>
<td>@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName)</td>
<td>@list.Status</td>
<td>
<a href="/admin/newsletter-lists/edit/@list.Id">Edit</a>

View File

@ -2,7 +2,8 @@
<h1>Preferences</h1>
<form method="post">
<input asp-for="SubscriptionId" type="hidden" />
<input asp-for="ListId" type="hidden" />
<input asp-for="Email" type="hidden" />
<label>Preferences JSON</label>
<textarea asp-for="PreferencesJson" rows="6"></textarea>
<span asp-validation-for="PreferencesJson"></span>