Implement unsubscribe token feature and enhance preferences management with list_id and email validation
This commit is contained in:
parent
f84cfb5beb
commit
e4af8f067f
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@ -0,0 +1,15 @@
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
.dotnet
|
||||
.nuget
|
||||
**/bin
|
||||
**/obj
|
||||
**/*.user
|
||||
**/*.suo
|
||||
**/*.swp
|
||||
**/*.log
|
||||
.env
|
||||
.env.*
|
||||
!/.env.example
|
||||
36
README.md
36
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)
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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 會員資料查看
|
||||
|
||||
@ -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` 的公開查詢/操作端點。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.");
|
||||
|
||||
18
src/MemberCenter.Api/Dockerfile
Normal file
18
src/MemberCenter.Api/Dockerfile
Normal 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"]
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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;
|
||||
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<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();
|
||||
}
|
||||
|
||||
|
||||
15
src/MemberCenter.Installer/Dockerfile
Normal file
15
src/MemberCenter.Installer/Dockerfile
Normal 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"]
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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.";
|
||||
|
||||
18
src/MemberCenter.Web/Dockerfile
Normal file
18
src/MemberCenter.Web/Dockerfile
Normal 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"]
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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; } = "{}";
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5060",
|
||||
"applicationUrl": "http://localhost:5080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user