From e4af8f067f93cd07343d8b45827d6c4e5c5bc356 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Wed, 4 Feb 2026 16:28:32 +0900 Subject: [PATCH] Implement unsubscribe token feature and enhance preferences management with list_id and email validation --- .dockerignore | 15 +++++ README.md | 36 +++++++--- docs/DESIGN.md | 2 + docs/FLOWS.md | 5 +- docs/OPENAPI.md | 6 ++ docs/UI.md | 2 +- docs/openapi.yaml | 35 ++++++++-- .../Contracts/NewsletterRequests.cs | 20 +++++- .../Controllers/NewsletterController.cs | 38 +++++++++-- src/MemberCenter.Api/Dockerfile | 18 +++++ .../Abstractions/INewsletterService.cs | 5 +- .../Models/Admin/NewsletterListDto.cs | 2 +- .../Services/NewsletterListService.cs | 30 ++++++--- .../Services/NewsletterService.cs | 67 +++++++++++++------ src/MemberCenter.Installer/Dockerfile | 15 +++++ .../Admin/NewsletterListsController.cs | 18 +++-- .../Controllers/NewsletterController.cs | 25 ++++++- src/MemberCenter.Web/Dockerfile | 18 +++++ .../Admin/NewsletterListFormViewModel.cs | 3 + .../Models/Newsletter/PreferencesViewModel.cs | 6 +- src/MemberCenter.Web/Program.cs | 7 +- .../Properties/launchSettings.json | 2 +- .../Views/Admin/NewsletterLists/Create.cshtml | 8 ++- .../Views/Admin/NewsletterLists/Edit.cshtml | 8 ++- .../Views/Admin/NewsletterLists/Index.cshtml | 5 +- .../Views/Newsletter/Preferences.cshtml | 3 +- 26 files changed, 329 insertions(+), 70 deletions(-) create mode 100644 .dockerignore create mode 100644 src/MemberCenter.Api/Dockerfile create mode 100644 src/MemberCenter.Installer/Dockerfile create mode 100644 src/MemberCenter.Web/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1846e1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +.vscode +.idea +.dotnet +.nuget +**/bin +**/obj +**/*.user +**/*.suo +**/*.swp +**/*.log +.env +.env.* +!/.env.example diff --git a/README.md b/README.md index 5ad64b0..8f4009a 100644 --- a/README.md +++ b/README.md @@ -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`:資料庫 schema(PostgreSQL) +- `docs/SEED.sql`:初始化/測試資料 +- `docs/TECH_STACK.md`:技術棧與選型 +- `docs/INSTALL.md`:安裝、初始化與維運指令 + +## 專案結構 +```text +member_center/ +├── src/ +│ ├── MemberCenter.Api/ # REST API(OAuth/OIDC、訂閱、管理 API) +│ ├── MemberCenter.Web/ # MVC Web UI(會員與後台頁面) +│ ├── MemberCenter.Installer/ # 安裝與初始化 CLI(migrate/init/admin) +│ ├── MemberCenter.Application/ # 應用層介面與 DTO +│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作 +│ └── MemberCenter.Domain/ # 領域實體與常數 +├── docs/ # 專案文件 +├── .vscode/ # 本機開發啟動與工作設定 +├── MemberCenter.sln # Solution +└── README.md +``` ## 待確認事項 - OAuth2 scopes 與最小 claims(email/profile) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f498d3d..3131dab 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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` diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 5ab5d32..aee2907 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -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 會員資料查看 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index a343525..d4d8bd2 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -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` 的公開查詢/操作端點。 diff --git a/docs/UI.md b/docs/UI.md index 8f098d3..be94990 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -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) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7e9d5e2..e6b6c5c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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': diff --git a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs index 63687b8..df1ad37 100644 --- a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs +++ b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs @@ -1,7 +1,21 @@ +using System.Text.Json.Serialization; + namespace MemberCenter.Api.Contracts; -public sealed record SubscribeRequest(Guid ListId, string Email, Dictionary? Preferences, string? Source); +public sealed record SubscribeRequest( + [property: JsonPropertyName("list_id")] Guid ListId, + [property: JsonPropertyName("email")] string Email, + [property: JsonPropertyName("preferences")] Dictionary? 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 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 Preferences); diff --git a/src/MemberCenter.Api/Controllers/NewsletterController.cs b/src/MemberCenter.Api/Controllers/NewsletterController.cs index 997c37a..79da4cf 100644 --- a/src/MemberCenter.Api/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Api/Controllers/NewsletterController.cs @@ -73,10 +73,35 @@ public class NewsletterController : ControllerBase }); } - [HttpGet("preferences")] - public async Task Preferences([FromQuery] Guid? subscriptionId, [FromQuery] string? email) + [HttpPost("unsubscribe-token")] + public async Task 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 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 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."); diff --git a/src/MemberCenter.Api/Dockerfile b/src/MemberCenter.Api/Dockerfile new file mode 100644 index 0000000..2758597 --- /dev/null +++ b/src/MemberCenter.Api/Dockerfile @@ -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"] diff --git a/src/MemberCenter.Application/Abstractions/INewsletterService.cs b/src/MemberCenter.Application/Abstractions/INewsletterService.cs index 1149b4c..5da3786 100644 --- a/src/MemberCenter.Application/Abstractions/INewsletterService.cs +++ b/src/MemberCenter.Application/Abstractions/INewsletterService.cs @@ -6,7 +6,8 @@ public interface INewsletterService { Task SubscribeAsync(Guid listId, string email, Dictionary? preferences); Task ConfirmAsync(string token); + Task IssueUnsubscribeTokenAsync(Guid listId, string email); Task UnsubscribeAsync(string token); - Task GetPreferencesAsync(Guid? subscriptionId, string? email); - Task UpdatePreferencesAsync(Guid subscriptionId, Dictionary preferences); + Task GetPreferencesAsync(Guid listId, string email); + Task UpdatePreferencesAsync(Guid listId, string email, Dictionary preferences); } diff --git a/src/MemberCenter.Application/Models/Admin/NewsletterListDto.cs b/src/MemberCenter.Application/Models/Admin/NewsletterListDto.cs index 334128a..fcd7f9c 100644 --- a/src/MemberCenter.Application/Models/Admin/NewsletterListDto.cs +++ b/src/MemberCenter.Application/Models/Admin/NewsletterListDto.cs @@ -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); diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterListService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterListService.cs index ce32506..b4eebf0 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterListService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterListService.cs @@ -17,14 +17,18 @@ public sealed class NewsletterListService : INewsletterListService public async Task> 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 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 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 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 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 GetTenantNameAsync(Guid tenantId) + { + return _dbContext.Tenants + .Where(t => t.Id == tenantId) + .Select(t => t.Name) + .FirstOrDefaultAsync(); } } diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs index 4009c0c..e6eefaf 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs @@ -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) @@ -44,7 +49,12 @@ public sealed class NewsletterService : INewsletterService } else { - subscription.Status = SubscriptionStatus.Pending; + // 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 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 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 GetPreferencesAsync(Guid? subscriptionId, string? email) + public async Task IssueUnsubscribeTokenAsync(Guid listId, string email) { - NewsletterSubscription? subscription = null; + var subscription = await _dbContext.NewsletterSubscriptions + .Where(s => s.ListId == listId && s.Email == email) + .OrderByDescending(s => s.CreatedAt) + .FirstOrDefaultAsync(); - if (subscriptionId.HasValue) + if (subscription is null) { - subscription = await _dbContext.NewsletterSubscriptions.FindAsync(subscriptionId.Value); + return null; } - else if (!string.IsNullOrWhiteSpace(email)) + + var token = CreateToken(); + _dbContext.UnsubscribeTokens.Add(new UnsubscribeToken { - subscription = await _dbContext.NewsletterSubscriptions - .Where(s => s.Email == email) - .OrderByDescending(s => s.CreatedAt) - .FirstOrDefaultAsync(); - } + Id = Guid.NewGuid(), + SubscriptionId = subscription.Id, + TokenHash = HashToken(token, UnsubscribeTokenPurpose), + ExpiresAt = DateTimeOffset.UtcNow.AddDays(UnsubscribeTokenTtlDays) + }); + + await _dbContext.SaveChangesAsync(); + return token; + } + + public async Task 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 UpdatePreferencesAsync(Guid subscriptionId, Dictionary preferences) + public async Task UpdatePreferencesAsync(Guid listId, string email, Dictionary 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(); } diff --git a/src/MemberCenter.Installer/Dockerfile b/src/MemberCenter.Installer/Dockerfile new file mode 100644 index 0000000..c169f57 --- /dev/null +++ b/src/MemberCenter.Installer/Dockerfile @@ -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"] diff --git a/src/MemberCenter.Web/Controllers/Admin/NewsletterListsController.cs b/src/MemberCenter.Web/Controllers/Admin/NewsletterListsController.cs index 41ebeeb..6da7f67 100644 --- a/src/MemberCenter.Web/Controllers/Admin/NewsletterListsController.cs +++ b/src/MemberCenter.Web/Controllers/Admin/NewsletterListsController.cs @@ -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 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); } diff --git a/src/MemberCenter.Web/Controllers/NewsletterController.cs b/src/MemberCenter.Web/Controllers/NewsletterController.cs index dbdd75a..0db72e1 100644 --- a/src/MemberCenter.Web/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Web/Controllers/NewsletterController.cs @@ -65,9 +65,21 @@ public class NewsletterController : Controller } [HttpGet] - public IActionResult Preferences(Guid subscriptionId) + public async Task 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()); + 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()); if (subscription is null) { ViewData["Result"] = "Subscription not found."; diff --git a/src/MemberCenter.Web/Dockerfile b/src/MemberCenter.Web/Dockerfile new file mode 100644 index 0000000..79b6bc6 --- /dev/null +++ b/src/MemberCenter.Web/Dockerfile @@ -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"] diff --git a/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs index 94fbb20..d5ff52d 100644 --- a/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs @@ -9,6 +9,9 @@ public sealed class NewsletterListFormViewModel [Required] public Guid TenantId { get; set; } + public IReadOnlyList Tenants { get; set; } + = Array.Empty(); + [Required] public string Name { get; set; } = string.Empty; diff --git a/src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs b/src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs index c194e5b..1275916 100644 --- a/src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs +++ b/src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs @@ -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; } = "{}"; } diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 434dc58..68bc283 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -58,7 +58,12 @@ builder.Services.AddOpenIddict() .ReplaceDefaultEntities(); }); -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(); diff --git a/src/MemberCenter.Web/Properties/launchSettings.json b/src/MemberCenter.Web/Properties/launchSettings.json index 50b25c1..a89ed51 100644 --- a/src/MemberCenter.Web/Properties/launchSettings.json +++ b/src/MemberCenter.Web/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5060", + "applicationUrl": "http://localhost:5080", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Create.cshtml b/src/MemberCenter.Web/Views/Admin/NewsletterLists/Create.cshtml index 49a53dd..ef4e527 100644 --- a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Create.cshtml +++ b/src/MemberCenter.Web/Views/Admin/NewsletterLists/Create.cshtml @@ -3,7 +3,13 @@

Create Newsletter List

- + diff --git a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Edit.cshtml b/src/MemberCenter.Web/Views/Admin/NewsletterLists/Edit.cshtml index c476d8f..f52e034 100644 --- a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Edit.cshtml +++ b/src/MemberCenter.Web/Views/Admin/NewsletterLists/Edit.cshtml @@ -3,7 +3,13 @@

Edit Newsletter List

- + diff --git a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Index.cshtml b/src/MemberCenter.Web/Views/Admin/NewsletterLists/Index.cshtml index ae54212..c6bbcdc 100644 --- a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Index.cshtml +++ b/src/MemberCenter.Web/Views/Admin/NewsletterLists/Index.cshtml @@ -4,14 +4,15 @@

Create

- + @foreach (var list in Model) { + - +
NameTenantStatus
List IDNameTenantStatus
@list.Id @list.Name@list.TenantId@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName) @list.Status Edit diff --git a/src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml b/src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml index 15116bf..3946aa0 100644 --- a/src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml +++ b/src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml @@ -2,7 +2,8 @@

Preferences

- + +