feat: Implement email blacklist functionality and completed auth for subscription sending flow
- Added EmailBlacklist service and controller for managing blacklisted emails. - Created EmailBlacklistDto for data transfer and EmailBlacklistFormViewModel for form handling. - Implemented views for listing and adding emails to the blacklist. - Updated database schema with new EmailBlacklist entity and related migrations. - Enhanced OAuthClientFormViewModel to include ClientId and ClientSecret properties. - Added EmailBlacklistService to handle email blacklisting logic. - Integrated email blacklist service into the application with necessary dependencies.
This commit is contained in:
parent
e4af8f067f
commit
33102d536e
@ -41,6 +41,8 @@
|
||||
- `docs/openapi.yaml`:OpenAPI 3.1 正式規格檔
|
||||
- `docs/SCHEMA.sql`:資料庫 schema(PostgreSQL)
|
||||
- `docs/SEED.sql`:初始化/測試資料
|
||||
- `docs/SEND_ENGINE.md`:自建發送引擎摘要(規劃)
|
||||
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
|
||||
- `docs/TECH_STACK.md`:技術棧與選型
|
||||
- `docs/INSTALL.md`:安裝、初始化與維運指令
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
- tenants
|
||||
- id, name, domains, status, created_at
|
||||
- users (ASP.NET Core Identity)
|
||||
- id, user_name, email, password_hash, email_confirmed, lockout, created_at
|
||||
- id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at
|
||||
- roles / user_roles (Identity)
|
||||
- id, name, created_at
|
||||
- OpenIddictApplications
|
||||
@ -54,6 +54,8 @@
|
||||
- id, tenant_id, name, status, created_at
|
||||
- newsletter_subscriptions
|
||||
- id, list_id, email, user_id (nullable), status, preferences, created_at
|
||||
- email_blacklist
|
||||
- id, email, reason, blacklisted_at, blacklisted_by
|
||||
- email_verifications
|
||||
- id, email, tenant_id, token_hash, purpose, expires_at, consumed_at
|
||||
- unsubscribe_tokens
|
||||
@ -67,6 +69,7 @@
|
||||
- newsletter_subscriptions.email 與 users.email 維持唯一性關聯
|
||||
- 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id
|
||||
- 單一清單退訂:unsubscribe token 綁定 subscription_id
|
||||
- blacklist 記錄於 email_blacklist(全租戶共用)
|
||||
|
||||
## 6. 核心流程
|
||||
|
||||
@ -94,6 +97,16 @@
|
||||
2) 驗證 token 後將該訂閱標記為 `unsubscribed`
|
||||
3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue
|
||||
|
||||
### 6.6 Send Engine 事件同步(Member Center → Send Engine)
|
||||
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`)
|
||||
2) 以 webhook 推送至 Send Engine(簽章與重放防護)
|
||||
3) Send Engine 驗證 tenant scope,更新本地名單快照
|
||||
|
||||
### 6.7 Send Engine 退信/黑名單回寫(選用)
|
||||
1) Send Engine 判定黑名單(例如 hard bounce / complaint)
|
||||
2) 呼叫 Member Center API 將 email 加入 `email_blacklist`
|
||||
3) Member Center 對該 email 的訂閱事件全部忽略且不再推送
|
||||
|
||||
### 6.5 註冊後銜接
|
||||
1) 使用者完成註冊
|
||||
2) 系統搜尋 `newsletter_subscriptions.email`
|
||||
@ -112,6 +125,27 @@
|
||||
- POST `/newsletter/unsubscribe-token`
|
||||
- GET `/newsletter/preferences`
|
||||
- POST `/newsletter/preferences`
|
||||
- POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫)
|
||||
- POST `/webhooks/lists/full-sync`(Send Engine 端點,Member Center 呼叫)
|
||||
- POST `/api/subscriptions/disable`(Member Center 端點,Send Engine 呼叫)
|
||||
|
||||
## 7.1 待新增 API / Auth(規劃中)
|
||||
### API
|
||||
- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單
|
||||
- `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送
|
||||
- `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步
|
||||
- `POST /api/subscriptions/disable`:Send Engine → Member Center 黑名單回寫
|
||||
|
||||
### Auth / Scope
|
||||
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
||||
- 新增 scope:`newsletter:list.read`、`newsletter:events.read`
|
||||
- 新增 scope:`newsletter:events.write`
|
||||
|
||||
### 租戶端取 Token(Client Credentials)
|
||||
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
||||
- token 內含 `tenant_id` 與 scope
|
||||
- Send Engine 收到租戶請求後以 JWKS 驗簽(建議)或向 Member Center 進行 introspection
|
||||
- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫
|
||||
|
||||
## 8. 安全與合規
|
||||
- 密碼強度與防暴力破解(rate limit + lockout)
|
||||
|
||||
@ -36,6 +36,16 @@
|
||||
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences`
|
||||
- [UI] 會員中心提供偏好頁(可選)
|
||||
|
||||
## F-10 Send Engine 事件同步(Member Center → Send Engine)
|
||||
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`)
|
||||
- [API] Send Engine 驗證 tenant scope + 簽章/重放防護
|
||||
- [API] Send Engine 更新名單快照
|
||||
|
||||
## F-11 黑名單回寫(Send Engine → Member Center)
|
||||
- [API] Send Engine 判定黑名單(例如 hard bounce / complaint)
|
||||
- [API] 呼叫 `POST /api/subscriptions/disable`(scope: `newsletter:events.write`)
|
||||
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
|
||||
|
||||
## F-07 會員資料查看
|
||||
- [API] 站點讀取 `/user/profile`
|
||||
- [UI] 會員中心提供個人資料頁
|
||||
|
||||
154
docs/MESSMAIL.md
Normal file
154
docs/MESSMAIL.md
Normal file
@ -0,0 +1,154 @@
|
||||
# 租戶站台介接指引(Member Center + Send Engine)
|
||||
|
||||
此文件給**租戶站台**使用,描述如何:
|
||||
- 訂閱 / 確認 / 退訂 / 偏好管理(Member Center)
|
||||
- 建立發信任務(Send Engine)
|
||||
|
||||
不包含 Member Center ↔ Send Engine 的內部事件同步細節。
|
||||
|
||||
---
|
||||
|
||||
## 1. 系統流程圖(租戶站台角度)
|
||||
|
||||
```
|
||||
Tenant Site
|
||||
|
|
||||
| (1) Subscribe
|
||||
v
|
||||
Member Center
|
||||
|
|
||||
| (2) Send confirmation email
|
||||
v
|
||||
End User
|
||||
|
|
||||
| (3) Confirm link
|
||||
v
|
||||
Member Center
|
||||
|
||||
Tenant Site
|
||||
|
|
||||
| (4) Request unsubscribe token
|
||||
v
|
||||
Member Center
|
||||
|
|
||||
| (5) Unsubscribe with token
|
||||
v
|
||||
Member Center
|
||||
|
||||
Tenant Site
|
||||
|
|
||||
| (6) Create send job
|
||||
v
|
||||
Send Engine
|
||||
|
|
||||
| (7) Deliver via ESP
|
||||
v
|
||||
End User
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Auth / Scope(租戶站台)
|
||||
|
||||
租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。
|
||||
|
||||
- 向 Member Center 申請 OAuth Client
|
||||
- scope 依用途最小化
|
||||
|
||||
建議 scopes:
|
||||
- `newsletter:send.write`(Send Engine 建立發信任務)
|
||||
- `newsletter:send.read`(查詢發信狀態,若需要)
|
||||
|
||||
> Send Engine 會驗證 token 內的 `tenant_id` 與 scope。
|
||||
|
||||
### 取得 Token(範例)
|
||||
```bash
|
||||
curl -s -X POST https://{member-center}/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials&client_id=<CLIENT_ID>&client_secret=<CLIENT_SECRET>&scope=newsletter:send.write"
|
||||
```
|
||||
|
||||
### 使用 Token(範例)
|
||||
```bash
|
||||
curl -s -X POST https://{send-engine}/api/send-jobs \
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ \"tenant_id\": \"<TENANT_ID>\", \"list_id\": \"<LIST_ID>\", \"subject\": \"Weekly\", \"body_text\": \"Hello\" }'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 表格(租戶站台 → Member Center)
|
||||
|
||||
| 功能 | Method | Path | 必填 | 說明 |
|
||||
|---|---|---|---|---|
|
||||
| 訂閱 | POST | `/newsletter/subscribe` | `list_id`, `email` | 回傳 `confirm_token` |
|
||||
| 確認訂閱 | GET | `/newsletter/confirm` | `token` | double opt-in 確認 |
|
||||
| 申請退訂 token | POST | `/newsletter/unsubscribe-token` | `list_id`, `email` | 回傳 `unsubscribe_token` |
|
||||
| 退訂 | POST | `/newsletter/unsubscribe` | `token` | 單一清單退訂 |
|
||||
| 讀取偏好 | GET | `/newsletter/preferences` | `list_id`, `email` | 需 list_id + email |
|
||||
| 更新偏好 | POST | `/newsletter/preferences` | `list_id`, `email`, `preferences` | 需 list_id + email |
|
||||
|
||||
### 訂閱(範例)
|
||||
```bash
|
||||
curl -s -X POST https://{member-center}/newsletter/subscribe \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"list_id": "<LIST_ID>",
|
||||
"email": "user@example.com",
|
||||
"preferences": {"lang": "zh-TW"}
|
||||
}'
|
||||
```
|
||||
|
||||
### 申請退訂 token(範例)
|
||||
```bash
|
||||
curl -s -X POST https://{member-center}/newsletter/unsubscribe-token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"list_id": "<LIST_ID>",
|
||||
"email": "user@example.com"
|
||||
}'
|
||||
```
|
||||
|
||||
### 退訂(範例)
|
||||
```bash
|
||||
curl -s -X POST https://{member-center}/newsletter/unsubscribe \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"token": "<UNSUBSCRIBE_TOKEN>"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 表格(租戶站台 → Send Engine)
|
||||
|
||||
| 功能 | Method | Path | 必填 | 說明 |
|
||||
|---|---|---|---|---|
|
||||
| 建立發信任務 | POST | `/api/send-jobs` | `list_id`, `subject`, `body_html/body_text` | 發送排程 |
|
||||
| 查詢發信任務 | GET | `/api/send-jobs/{id}` | `id` | 讀取狀態 |
|
||||
| 取消發信任務 | POST | `/api/send-jobs/{id}/cancel` | `id` | 取消發送 |
|
||||
|
||||
### 建立發信任務(範例)
|
||||
```bash
|
||||
curl -s -X POST https://{send-engine}/api/send-jobs \
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tenant_id": "<TENANT_ID>",
|
||||
"list_id": "<LIST_ID>",
|
||||
"name": "Weekly Update",
|
||||
"subject": "Weekly Update",
|
||||
"body_html": "<p>Hello</p>",
|
||||
"body_text": "Hello",
|
||||
"scheduled_at": "2026-02-11T02:00:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全注意事項(租戶站台)
|
||||
|
||||
- `list_id + email` 為租戶隔離最小邊界,必須一併提供
|
||||
- 不要保存退訂 token;使用時再向 Member Center 申請
|
||||
- Token 必須妥善保管,避免跨租戶濫用
|
||||
@ -24,6 +24,34 @@
|
||||
- `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token`
|
||||
- `/newsletter/preferences`(GET/POST)需要 `list_id + email`,避免跨租戶資料讀取/更新
|
||||
|
||||
## 通用欄位
|
||||
- `occurred_at`:RFC3339(例:`2026-02-10T09:30:00Z`)
|
||||
- `event_id`、`request_id`:UUID
|
||||
|
||||
## 通用錯誤格式
|
||||
```json
|
||||
{
|
||||
"error": "string_code",
|
||||
"message": "human readable message",
|
||||
"request_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## 多租戶資料隔離原則
|
||||
- 與訂閱者資料(preferences、unsubscribe token)相關的查詢與寫入,一律必須帶 `list_id + email` 做租戶邊界約束。
|
||||
- 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。
|
||||
|
||||
## 待新增 API / Auth(規劃中)
|
||||
### API
|
||||
- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用)
|
||||
- `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送(Send Engine 端點,scope: `newsletter:events.write`)
|
||||
- `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步(Send Engine 端點,scope: `newsletter:events.write`)
|
||||
- `POST /api/subscriptions/disable`:Send Engine → Member Center 黑名單回寫(全租戶 email,scope: `newsletter:events.write`)
|
||||
|
||||
### Auth / Scope
|
||||
- OAuth Client 需綁定 `tenant_id`
|
||||
- 新增 scope:
|
||||
- `newsletter:list.read`
|
||||
- `newsletter:events.read`
|
||||
- `newsletter:events.write`
|
||||
- 發送引擎僅能用上述 scope,禁止 admin 權限
|
||||
|
||||
@ -27,6 +27,9 @@ CREATE TABLE users (
|
||||
lockout_end TIMESTAMPTZ,
|
||||
lockout_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
access_failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
blacklisted_at TIMESTAMPTZ,
|
||||
blacklisted_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@ -160,6 +163,14 @@ CREATE TABLE newsletter_subscriptions (
|
||||
UNIQUE (list_id, email)
|
||||
);
|
||||
|
||||
CREATE TABLE email_blacklist (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
reason TEXT NOT NULL,
|
||||
blacklisted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
blacklisted_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE email_verifications (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
### 管理者端
|
||||
- 租戶管理(Tenant CRUD)
|
||||
- OAuth Client 管理(redirect_uris / scopes)
|
||||
- OAuth Client 管理(redirect_uris / scopes / client_id / client_secret)
|
||||
- 電子報清單管理(Lists CRUD)
|
||||
- 訂閱查詢 / 匯出
|
||||
- 審計紀錄查詢
|
||||
@ -48,7 +48,7 @@
|
||||
|
||||
### 管理者端(統一 UI)
|
||||
- UC-11 租戶管理: `/admin/tenants`
|
||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`
|
||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉)
|
||||
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
||||
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
||||
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
||||
|
||||
@ -228,6 +228,18 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PendingSubscriptionResponse'
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/newsletter/confirm:
|
||||
get:
|
||||
@ -245,6 +257,18 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/newsletter/unsubscribe:
|
||||
post:
|
||||
@ -266,6 +290,18 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/newsletter/unsubscribe-token:
|
||||
post:
|
||||
@ -290,6 +326,18 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
unsubscribe_token: { type: string }
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/newsletter/preferences:
|
||||
get:
|
||||
@ -310,6 +358,18 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
post:
|
||||
summary: Update preferences
|
||||
@ -331,6 +391,107 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/newsletter/subscriptions:
|
||||
get:
|
||||
summary: List subscriptions by list
|
||||
security: [{ BearerAuth: [] }]
|
||||
description: Requires scope `newsletter:list.read`.
|
||||
parameters:
|
||||
- in: query
|
||||
name: list_id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: List
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/api/subscriptions/disable:
|
||||
post:
|
||||
summary: Disable email (global blacklist)
|
||||
security: [{ BearerAuth: [] }]
|
||||
description: Requires scope `newsletter:events.write`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DisableSubscriptionRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Disabled
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/webhooks/subscriptions:
|
||||
post:
|
||||
summary: (NOTE) Member Center -> Send Engine subscription events webhook
|
||||
description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendEngineSubscriptionEvent'
|
||||
responses:
|
||||
'200':
|
||||
description: Accepted
|
||||
|
||||
/webhooks/lists/full-sync:
|
||||
post:
|
||||
summary: (NOTE) Member Center -> Send Engine full list sync webhook
|
||||
description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendEngineListFullSync'
|
||||
responses:
|
||||
'200':
|
||||
description: Accepted
|
||||
|
||||
/admin/tenants:
|
||||
get:
|
||||
@ -632,6 +793,62 @@ components:
|
||||
properties:
|
||||
confirm_token: { type: string }
|
||||
|
||||
DisableSubscriptionRequest:
|
||||
type: object
|
||||
required: [email, reason, disabled_by, occurred_at]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
reason: { type: string }
|
||||
disabled_by: { type: string }
|
||||
occurred_at: { type: string, format: date-time }
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [error, message, request_id]
|
||||
properties:
|
||||
error: { type: string }
|
||||
message: { type: string }
|
||||
request_id: { type: string }
|
||||
|
||||
SendEngineSubscriptionEvent:
|
||||
type: object
|
||||
required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at]
|
||||
properties:
|
||||
event_id: { type: string }
|
||||
event_type: { type: string, enum: [subscription.activated, subscription.unsubscribed, preferences.updated] }
|
||||
tenant_id: { type: string }
|
||||
list_id: { type: string }
|
||||
subscriber:
|
||||
type: object
|
||||
required: [id, email, status]
|
||||
properties:
|
||||
id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
status: { type: string, enum: [pending, active, unsubscribed] }
|
||||
preferences: { type: object }
|
||||
occurred_at: { type: string, format: date-time }
|
||||
|
||||
SendEngineListFullSync:
|
||||
type: object
|
||||
required: [sync_id, batch_no, batch_total, tenant_id, list_id, subscribers, occurred_at]
|
||||
properties:
|
||||
sync_id: { type: string }
|
||||
batch_no: { type: integer }
|
||||
batch_total: { type: integer }
|
||||
tenant_id: { type: string }
|
||||
list_id: { type: string }
|
||||
subscribers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [id, email, status]
|
||||
properties:
|
||||
id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
status: { type: string, enum: [pending, active, unsubscribed] }
|
||||
preferences: { type: object }
|
||||
occurred_at: { type: string, format: date-time }
|
||||
|
||||
Tenant:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -19,3 +19,9 @@ public sealed record UpdatePreferencesRequest(
|
||||
[property: JsonPropertyName("list_id")] Guid ListId,
|
||||
[property: JsonPropertyName("email")] string Email,
|
||||
[property: JsonPropertyName("preferences")] Dictionary<string, object> Preferences);
|
||||
|
||||
public sealed record DisableSubscriptionRequest(
|
||||
[property: JsonPropertyName("email")] string Email,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("disabled_by")] string DisabledBy,
|
||||
[property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt);
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
|
||||
@ -140,4 +142,37 @@ public class NewsletterController : ControllerBase
|
||||
preferences = subscription.Preferences
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("subscriptions")]
|
||||
public async Task<IActionResult> ListSubscriptions([FromQuery(Name = "list_id")] Guid listId)
|
||||
{
|
||||
if (!HasScope(User, "newsletter:list.read"))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (listId == Guid.Empty)
|
||||
{
|
||||
return BadRequest("list_id is required.");
|
||||
}
|
||||
|
||||
var subscriptions = await _newsletterService.ListSubscriptionsAsync(listId);
|
||||
return Ok(subscriptions.Select(s => new
|
||||
{
|
||||
id = s.Id,
|
||||
list_id = s.ListId,
|
||||
email = s.Email,
|
||||
status = s.Status,
|
||||
preferences = s.Preferences,
|
||||
created_at = s.CreatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
51
src/MemberCenter.Api/Controllers/SubscriptionsController.cs
Normal file
51
src/MemberCenter.Api/Controllers/SubscriptionsController.cs
Normal file
@ -0,0 +1,51 @@
|
||||
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("api/subscriptions")]
|
||||
public class SubscriptionsController : ControllerBase
|
||||
{
|
||||
private readonly IEmailBlacklistService _emailBlacklistService;
|
||||
|
||||
public SubscriptionsController(IEmailBlacklistService emailBlacklistService)
|
||||
{
|
||||
_emailBlacklistService = emailBlacklistService;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("disable")]
|
||||
public async Task<IActionResult> Disable([FromBody] DisableSubscriptionRequest request)
|
||||
{
|
||||
if (!HasScope(User, "newsletter:events.write"))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Email) ||
|
||||
string.IsNullOrWhiteSpace(request.Reason) ||
|
||||
string.IsNullOrWhiteSpace(request.DisabledBy))
|
||||
{
|
||||
return BadRequest("email, reason, disabled_by are required.");
|
||||
}
|
||||
|
||||
await _emailBlacklistService.AddOrUpdateAsync(
|
||||
request.Email,
|
||||
request.Reason,
|
||||
request.DisabledBy,
|
||||
request.OccurredAt);
|
||||
|
||||
return Ok(new { email = request.Email, status = "blacklisted" });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -87,6 +87,7 @@ builder.Services.AddAuthorization(options =>
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddScoped<INewsletterService, NewsletterService>();
|
||||
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
||||
builder.Services.AddScoped<ITenantService, TenantService>();
|
||||
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IEmailBlacklistService
|
||||
{
|
||||
Task<bool> IsBlacklistedAsync(string email);
|
||||
Task AddOrUpdateAsync(string email, string reason, string blacklistedBy, DateTimeOffset occurredAt);
|
||||
Task<IReadOnlyList<MemberCenter.Application.Models.Admin.EmailBlacklistDto>> ListAsync(int take = 200);
|
||||
}
|
||||
@ -10,4 +10,5 @@ public interface INewsletterService
|
||||
Task<SubscriptionDto?> UnsubscribeAsync(string token);
|
||||
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
|
||||
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
|
||||
Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId);
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record EmailBlacklistDto(
|
||||
string Email,
|
||||
string Reason,
|
||||
string BlacklistedBy,
|
||||
DateTimeOffset BlacklistedAt);
|
||||
10
src/MemberCenter.Domain/Entities/EmailBlacklist.cs
Normal file
10
src/MemberCenter.Domain/Entities/EmailBlacklist.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class EmailBlacklist
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public DateTimeOffset BlacklistedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string BlacklistedBy { get; set; } = string.Empty;
|
||||
}
|
||||
@ -5,4 +5,7 @@ namespace MemberCenter.Infrastructure.Identity;
|
||||
public class ApplicationUser : IdentityUser<Guid>
|
||||
{
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public bool IsBlacklisted { get; set; }
|
||||
public DateTimeOffset? BlacklistedAt { get; set; }
|
||||
public string? BlacklistedBy { get; set; }
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ public class MemberCenterDbContext
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>();
|
||||
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
|
||||
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
|
||||
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
|
||||
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
@ -75,6 +76,17 @@ public class MemberCenterDbContext
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<EmailBlacklist>(entity =>
|
||||
{
|
||||
entity.ToTable("email_blacklist");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Email).IsRequired();
|
||||
entity.Property(x => x.Reason).IsRequired();
|
||||
entity.Property(x => x.BlacklistedAt).HasDefaultValueSql("now()");
|
||||
entity.Property(x => x.BlacklistedBy).IsRequired();
|
||||
entity.HasIndex(x => x.Email).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<EmailVerification>(entity =>
|
||||
{
|
||||
entity.ToTable("email_verifications");
|
||||
@ -128,6 +140,7 @@ public class MemberCenterDbContext
|
||||
{
|
||||
entity.ToTable("users");
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.Property(x => x.IsBlacklisted).HasDefaultValue(false);
|
||||
});
|
||||
|
||||
builder.Entity<ApplicationRole>(entity =>
|
||||
|
||||
796
src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.Designer.cs
generated
Normal file
796
src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.Designer.cs
generated
Normal file
@ -0,0 +1,796 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MemberCenterDbContext))]
|
||||
[Migration("20260210170000_AddEmailBlacklist")]
|
||||
partial class AddEmailBlacklist
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("audit_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("BlacklistedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("BlacklistedBy")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_blacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.HasDatabaseName("idx_email_verifications_email");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("email_verifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("newsletter_lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("ListId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Preferences")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("pending");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.HasDatabaseName("idx_newsletter_subscriptions_email");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_newsletter_subscriptions_list_id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("ListId", "Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("newsletter_subscriptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.SystemFlag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("system_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("SubscriptionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriptionId");
|
||||
|
||||
b.ToTable("unsubscribe_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<List<string>>("Domains")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("roles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("BlacklistedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BlacklistedBy")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlacklisted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("role_claims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("user_claims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("user_logins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("user_roles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("user_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication<string>", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ApplicationType")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ClientSecret")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClientType")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("ConcurrencyToken")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("ConsentType")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayNames")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("JsonWebKeySet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Permissions")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PostLogoutRedirectUris")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Properties")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RedirectUris")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Requirements")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Settings")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OpenIddictApplications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization<string>", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ApplicationId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyToken")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<DateTimeOffset?>("CreationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Properties")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Scopes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApplicationId");
|
||||
|
||||
b.ToTable("OpenIddictAuthorizations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope<string>", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyToken")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Descriptions")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayNames")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Properties")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Resources")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OpenIddictScopes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ApplicationId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AuthorizationId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("CreationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpirationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Properties")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RedemptionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReferenceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApplicationId");
|
||||
|
||||
b.HasIndex("AuthorizationId");
|
||||
|
||||
b.ToTable("OpenIddictTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant")
|
||||
.WithMany("NewsletterLists")
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.NewsletterList", "List")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Subscription");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization<string>", b =>
|
||||
{
|
||||
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication<string>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ApplicationId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken<string>", b =>
|
||||
{
|
||||
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication<string>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ApplicationId");
|
||||
|
||||
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization<string>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AuthorizationId");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
public partial class AddEmailBlacklist : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBlacklisted",
|
||||
table: "users",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "BlacklistedAt",
|
||||
table: "users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BlacklistedBy",
|
||||
table: "users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "email_blacklist",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Email = table.Column<string>(type: "text", nullable: false),
|
||||
Reason = table.Column<string>(type: "text", nullable: false),
|
||||
BlacklistedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
BlacklistedBy = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_email_blacklist", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_email_blacklist_Email",
|
||||
table: "email_blacklist",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "email_blacklist");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBlacklisted",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BlacklistedAt",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BlacklistedBy",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,37 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("audit_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("BlacklistedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("BlacklistedBy")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_blacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -293,6 +324,12 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("BlacklistedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BlacklistedBy")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
@ -309,6 +346,11 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlacklisted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Admin;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class EmailBlacklistService : IEmailBlacklistService
|
||||
{
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
|
||||
public EmailBlacklistService(MemberCenterDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public Task<bool> IsBlacklistedAsync(string email)
|
||||
{
|
||||
var normalized = Normalize(email);
|
||||
return _dbContext.EmailBlacklist.AnyAsync(x => x.Email == normalized);
|
||||
}
|
||||
|
||||
public async Task AddOrUpdateAsync(string email, string reason, string blacklistedBy, DateTimeOffset occurredAt)
|
||||
{
|
||||
var normalized = Normalize(email);
|
||||
var existing = await _dbContext.EmailBlacklist.FirstOrDefaultAsync(x => x.Email == normalized);
|
||||
if (existing is null)
|
||||
{
|
||||
_dbContext.EmailBlacklist.Add(new EmailBlacklist
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = normalized,
|
||||
Reason = reason,
|
||||
BlacklistedAt = occurredAt,
|
||||
BlacklistedBy = blacklistedBy
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Reason = reason;
|
||||
existing.BlacklistedAt = occurredAt;
|
||||
existing.BlacklistedBy = blacklistedBy;
|
||||
}
|
||||
|
||||
var user = await _dbContext.Set<ApplicationUser>()
|
||||
.FirstOrDefaultAsync(u => u.Email != null && u.Email.ToLower() == normalized);
|
||||
if (user is not null)
|
||||
{
|
||||
user.IsBlacklisted = true;
|
||||
user.BlacklistedAt = occurredAt;
|
||||
user.BlacklistedBy = blacklistedBy;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailBlacklistDto>> ListAsync(int take = 200)
|
||||
{
|
||||
var items = await _dbContext.EmailBlacklist
|
||||
.OrderByDescending(x => x.BlacklistedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return items.Select(x => new EmailBlacklistDto(
|
||||
x.Email,
|
||||
x.Reason,
|
||||
x.BlacklistedBy,
|
||||
x.BlacklistedAt)).ToList();
|
||||
}
|
||||
|
||||
private static string Normalize(string email)
|
||||
{
|
||||
return email.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@ -18,14 +18,21 @@ public sealed class NewsletterService : INewsletterService
|
||||
private const int UnsubscribeTokenTtlDays = 7;
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly IEmailBlacklistService _emailBlacklist;
|
||||
|
||||
public NewsletterService(MemberCenterDbContext dbContext)
|
||||
public NewsletterService(MemberCenterDbContext dbContext, IEmailBlacklistService emailBlacklist)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_emailBlacklist = emailBlacklist;
|
||||
}
|
||||
|
||||
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
|
||||
{
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (list is null)
|
||||
{
|
||||
@ -84,6 +91,11 @@ public sealed class NewsletterService : INewsletterService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(confirmToken.Subscription.Email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
{
|
||||
return null;
|
||||
@ -108,6 +120,11 @@ public sealed class NewsletterService : INewsletterService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(unsubscribeToken.Subscription.Email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
unsubscribeToken.Subscription.Status = SubscriptionStatus.Unsubscribed;
|
||||
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
@ -117,6 +134,11 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
public async Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email)
|
||||
{
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subscription = await _dbContext.NewsletterSubscriptions
|
||||
.Where(s => s.ListId == listId && s.Email == email)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
@ -142,6 +164,11 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email)
|
||||
{
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subscription = await _dbContext.NewsletterSubscriptions
|
||||
.Where(s => s.ListId == listId && s.Email == email)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
@ -152,6 +179,11 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
public async Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences)
|
||||
{
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subscription = await _dbContext.NewsletterSubscriptions
|
||||
.Where(s => s.ListId == listId && s.Email == email)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
@ -167,6 +199,17 @@ public sealed class NewsletterService : INewsletterService
|
||||
return MapSubscription(subscription);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId)
|
||||
{
|
||||
var blacklisted = _dbContext.EmailBlacklist.Select(x => x.Email);
|
||||
var subscriptions = await _dbContext.NewsletterSubscriptions
|
||||
.Where(s => s.ListId == listId && !blacklisted.Contains(s.Email.ToLower()))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return subscriptions.Select(MapSubscription).ToList();
|
||||
}
|
||||
|
||||
private static string CreateToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Web.Models.Admin;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MemberCenter.Web.Controllers.Admin;
|
||||
|
||||
[Authorize(Policy = "Admin")]
|
||||
[Route("admin/blacklist")]
|
||||
public class BlacklistController : Controller
|
||||
{
|
||||
private readonly IEmailBlacklistService _blacklistService;
|
||||
|
||||
public BlacklistController(IEmailBlacklistService blacklistService)
|
||||
{
|
||||
_blacklistService = blacklistService;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var items = await _blacklistService.ListAsync();
|
||||
return View(items);
|
||||
}
|
||||
|
||||
[HttpGet("create")]
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new EmailBlacklistFormViewModel());
|
||||
}
|
||||
|
||||
[HttpPost("create")]
|
||||
public async Task<IActionResult> Create(EmailBlacklistFormViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
await _blacklistService.AddOrUpdateAsync(
|
||||
model.Email,
|
||||
model.Reason,
|
||||
"admin",
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
}
|
||||
@ -49,9 +49,14 @@ public class OAuthClientsController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var clientId = Guid.NewGuid().ToString("N");
|
||||
var clientSecret = model.ClientType == "confidential"
|
||||
? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||
: null;
|
||||
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = Guid.NewGuid().ToString("N"),
|
||||
ClientId = clientId,
|
||||
DisplayName = model.Name,
|
||||
ClientType = model.ClientType,
|
||||
Permissions =
|
||||
@ -66,6 +71,10 @@ public class OAuthClientsController : Controller
|
||||
"scp:openid"
|
||||
}
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(clientSecret))
|
||||
{
|
||||
descriptor.ClientSecret = clientSecret;
|
||||
}
|
||||
|
||||
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
@ -75,6 +84,13 @@ public class OAuthClientsController : Controller
|
||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
|
||||
|
||||
await _applicationManager.CreateAsync(descriptor);
|
||||
|
||||
TempData["CreatedClientId"] = clientId;
|
||||
if (!string.IsNullOrWhiteSpace(clientSecret))
|
||||
{
|
||||
TempData["CreatedClientSecret"] = clientSecret;
|
||||
}
|
||||
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
@ -142,4 +158,33 @@ public class OAuthClientsController : Controller
|
||||
await _applicationManager.DeleteAsync(app);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
[HttpPost("rotate-secret/{id}")]
|
||||
public async Task<IActionResult> RotateSecret(string id)
|
||||
{
|
||||
var app = await _applicationManager.FindByIdAsync(id);
|
||||
if (app is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var clientType = await _applicationManager.GetClientTypeAsync(app);
|
||||
if (!string.Equals(clientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Only confidential clients have secrets.");
|
||||
}
|
||||
|
||||
var descriptor = new OpenIddictApplicationDescriptor();
|
||||
await _applicationManager.PopulateAsync(descriptor, app);
|
||||
|
||||
var newSecret = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
|
||||
descriptor.ClientSecret = newSecret;
|
||||
|
||||
await _applicationManager.UpdateAsync(app, descriptor);
|
||||
|
||||
TempData["RotatedClientId"] = await _applicationManager.GetClientIdAsync(app);
|
||||
TempData["RotatedClientSecret"] = newSecret;
|
||||
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MemberCenter.Web.Models.Admin;
|
||||
|
||||
public sealed class EmailBlacklistFormViewModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@ -14,4 +14,7 @@ public sealed class OAuthClientFormViewModel
|
||||
public string ClientType { get; set; } = "public";
|
||||
|
||||
public string RedirectUris { get; set; } = string.Empty;
|
||||
|
||||
public string? ClientId { get; set; }
|
||||
public string? ClientSecret { get; set; }
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ builder.Services.AddAuthorization(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<INewsletterService, NewsletterService>();
|
||||
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
||||
builder.Services.AddScoped<ITenantService, TenantService>();
|
||||
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
||||
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
||||
|
||||
14
src/MemberCenter.Web/Views/Admin/Blacklist/Create.cshtml
Normal file
14
src/MemberCenter.Web/Views/Admin/Blacklist/Create.cshtml
Normal file
@ -0,0 +1,14 @@
|
||||
@model MemberCenter.Web.Models.Admin.EmailBlacklistFormViewModel
|
||||
|
||||
<h1>Add Email Blacklist</h1>
|
||||
<form method="post">
|
||||
<label>Email</label>
|
||||
<input asp-for="Email" />
|
||||
<span asp-validation-for="Email"></span>
|
||||
|
||||
<label>Reason</label>
|
||||
<input asp-for="Reason" />
|
||||
<span asp-validation-for="Reason"></span>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
20
src/MemberCenter.Web/Views/Admin/Blacklist/Index.cshtml
Normal file
20
src/MemberCenter.Web/Views/Admin/Blacklist/Index.cshtml
Normal file
@ -0,0 +1,20 @@
|
||||
@model IReadOnlyList<MemberCenter.Application.Models.Admin.EmailBlacklistDto>
|
||||
|
||||
<h1>Email Blacklist</h1>
|
||||
<p><a href="/admin/blacklist/create">Add</a></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Email</th><th>Reason</th><th>By</th><th>At</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Email</td>
|
||||
<td>@item.Reason</td>
|
||||
<td>@item.BlacklistedBy</td>
|
||||
<td>@item.BlacklistedAt</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -2,6 +2,28 @@
|
||||
|
||||
<h1>OAuth Clients</h1>
|
||||
<p><a href="/admin/oauth-clients/create">Create</a></p>
|
||||
@if (TempData["CreatedClientId"] is string createdId)
|
||||
{
|
||||
<div>
|
||||
<strong>Client Created</strong><br />
|
||||
<div>Client ID: <code>@createdId</code></div>
|
||||
@if (TempData["CreatedClientSecret"] is string createdSecret)
|
||||
{
|
||||
<div>Client Secret (show once): <code>@createdSecret</code></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (TempData["RotatedClientId"] is string rotatedId)
|
||||
{
|
||||
<div>
|
||||
<strong>Client Secret Rotated</strong><br />
|
||||
<div>Client ID: <code>@rotatedId</code></div>
|
||||
@if (TempData["RotatedClientSecret"] is string rotatedSecret)
|
||||
{
|
||||
<div>New Client Secret (show once): <code>@rotatedSecret</code></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Client Id</th><th>Type</th><th></th></tr>
|
||||
@ -19,6 +41,12 @@
|
||||
<td>@clientType</td>
|
||||
<td>
|
||||
<a href="/admin/oauth-clients/edit/@id">Edit</a>
|
||||
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<form method="post" action="/admin/oauth-clients/rotate-secret/@id" style="display:inline">
|
||||
<button type="submit">Rotate Secret</button>
|
||||
</form>
|
||||
}
|
||||
<form method="post" action="/admin/oauth-clients/delete/@id" style="display:inline">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
<a href="/admin/oauth-clients">OAuth Clients</a>
|
||||
<a href="/admin/audit-logs">Audit Logs</a>
|
||||
<a href="/admin/security">Security</a>
|
||||
<a href="/admin/blacklist">Blacklist</a>
|
||||
<a href="/account/login">Login</a>
|
||||
<form method="post" action="/account/logout" style="display:inline">
|
||||
<button type="submit">Logout</button>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user