Compare commits

..

2 Commits

Author SHA1 Message Date
warrenchen
8756010173 Refactor installation process and update schema definitions for improved functionality 2026-02-03 15:33:16 +09:00
warrenchen
4631f82ee4 Add initial installer project and setup for MemberCenter
- Created MemberCenter.Installer project with references to Infrastructure, Application, and Domain projects.
- Added Program.cs with a basic console output.
- Generated MemberCenterDbContextModelSnapshot for database schema representation.
2026-02-03 15:04:18 +09:00
44 changed files with 4367 additions and 114 deletions

13
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.11",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

8
.gitignore vendored
View File

@ -32,6 +32,8 @@ artifacts/
# NuGet
*.nupkg
*.snupkg
# Local NuGet/global package caches in repo
.nuget/
# The packages folder can be ignored because of PackageReference
# Uncomment if using packages.config
#packages/
@ -63,6 +65,9 @@ ScaffoldingReadMe.txt
secrets.json
appsettings.*.json
!appsettings.json
.env
.env.*
!.env.example
# OS
.DS_Store
@ -71,3 +76,6 @@ Thumbs.db
# Others
*.swp
*.tmp
*.pid
*.pid.lock
.dotnet/

6
Directory.Build.props Normal file
View File

@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<RunAnalyzers>false</RunAnalyzers>
<EnableNETAnalyzers>false</EnableNETAnalyzers>
</PropertyGroup>
</Project>

55
MemberCenter.sln Normal file
View File

@ -0,0 +1,55 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{150D3A20-BF61-4012-BD40-05D408749112}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Domain", "src\MemberCenter.Domain\MemberCenter.Domain.csproj", "{7733733D-22EB-431D-A8AA-833486C3E0E2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Application", "src\MemberCenter.Application\MemberCenter.Application.csproj", "{90EC27FD-E72D-4506-A81A-BD81F4D555CF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Infrastructure", "src\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj", "{28015B2B-16F2-4DA0-9DA6-D79C94330A4D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Api", "src\MemberCenter.Api\MemberCenter.Api.csproj", "{051ECE48-E49B-4E42-BE08-6E9AAB7262BC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Installer", "src\MemberCenter.Installer\MemberCenter.Installer.csproj", "{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7733733D-22EB-431D-A8AA-833486C3E0E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7733733D-22EB-431D-A8AA-833486C3E0E2}.Release|Any CPU.Build.0 = Release|Any CPU
{90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Release|Any CPU.Build.0 = Release|Any CPU
{28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Release|Any CPU.Build.0 = Release|Any CPU
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Release|Any CPU.Build.0 = Release|Any CPU
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112}
{90EC27FD-E72D-4506-A81A-BD81F4D555CF} = {150D3A20-BF61-4012-BD40-05D408749112}
{28015B2B-16F2-4DA0-9DA6-D79C94330A4D} = {150D3A20-BF61-4012-BD40-05D408749112}
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112}
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2} = {150D3A20-BF61-4012-BD40-05D408749112}
EndGlobalSection
EndGlobal

9
NuGet.Config Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="globalPackagesFolder" value=".nuget/packages" />
</config>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

View File

@ -21,22 +21,12 @@
- 強制重設 superuser 密碼
- 只執行 migration差異安裝不重建 schema
## 建議實作方式(.NET
- 提供一個 CLI 或安裝頁面(類 nopCommerce / Wagtail
- CLI 支援互動式輸入DB 連線、admin 帳號、密碼)
- 內部流程一致:
1) 讀取/寫入連線資訊
2) 執行 migration`dbContext.Database.Migrate()`
3) 建立角色與 superuser若不存在
## CLI 指令規格(草案)
## CLI 指令規格(實作)
### 通用參數
- `--connection-string <string>`: DB 連線字串
- `--db-provider <string>`: 預設 `postgres`
- `--appsettings <path>`: 設定檔路徑(預設 `appsettings.json`
- `--appsettings <path>`: 設定檔路徑(讀/寫 `ConnectionStrings:Default`
- `--no-prompt`: 不使用互動輸入CI/CD
- `--dry-run`: 只檢查,不寫入
- `--verbose`: 詳細輸出
### 1) `installer init`
@ -49,11 +39,13 @@
- `--force`: 若偵測已初始化,仍強制執行
流程:
1) 讀取/寫入 DB 連線資訊
1) 解析連線字串(參數或 appsettings
- 若提供 `--connection-string`,會寫入 appsettings
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入
2) 執行 migrations不 Drop
3) 建立 rolesadmin, support
4) 建立 admin不存在才建立
5) 寫入安裝鎖定(install.lock 或 DB flag
4) 建立 admin不存在才建立並加入 admin 角色
5) 寫入安裝鎖定DB flag: `system_flags` / `installed=true`
### 2) `installer add-admin`
用途:新增 superuser
@ -64,7 +56,7 @@
- `--admin-display-name <string>`
流程:
1) 檢查 DB 連線
1) 解析連線字串
2) 建立使用者並指派 admin 角色
### 3) `installer reset-admin-password`
@ -75,9 +67,8 @@
- `--admin-password <string>`
流程:
1) 檢查帳號存在
1) 解析連線字串
2) 更新密碼(強制)
3) 寫入 audit log
### 4) `installer migrate`
用途:只執行 migrations
@ -86,9 +77,8 @@
- `--target <migration>`: 指定遷移(可選)
流程:
1) 連線檢查
2) 執行 migrations
3) 輸出版本
1) 解析連線字串
2) 執行 migrations可指定 target
## 安全注意
- 密碼必須符合強度規則

View File

@ -1,4 +1,7 @@
-- PostgreSQL schema draft (MVP)
-- PostgreSQL schema draft (MVP)
-- Note: OpenIddict/Identity tables are based on EF Core migrations.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE tenants (
id UUID PRIMARY KEY,
@ -10,62 +13,137 @@ CREATE TABLE tenants (
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active',
user_name TEXT,
normalized_user_name TEXT,
email TEXT,
normalized_email TEXT,
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
password_hash TEXT,
security_stamp TEXT,
concurrency_stamp TEXT,
phone_number TEXT,
phone_number_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
lockout_end TIMESTAMPTZ,
lockout_enabled BOOLEAN NOT NULL DEFAULT FALSE,
access_failed_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE oauth_clients (
CREATE TABLE roles (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT,
normalized_name TEXT,
concurrency_stamp TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE user_claims (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
claim_type TEXT,
claim_value TEXT
);
CREATE TABLE role_claims (
id SERIAL PRIMARY KEY,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
claim_type TEXT,
claim_value TEXT
);
CREATE TABLE user_logins (
login_provider TEXT NOT NULL,
provider_key TEXT NOT NULL,
provider_display_name TEXT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (login_provider, provider_key)
);
CREATE TABLE user_tokens (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
login_provider TEXT NOT NULL,
name TEXT NOT NULL,
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
client_type TEXT NOT NULL DEFAULT 'confidential',
client_secret_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
value TEXT,
PRIMARY KEY (user_id, login_provider, name)
);
ALTER TABLE oauth_clients
ADD CONSTRAINT chk_oauth_clients_secret
CHECK (
(client_type = 'confidential' AND client_secret_hash IS NOT NULL)
OR (client_type <> 'confidential')
CREATE UNIQUE INDEX idx_users_normalized_user_name ON users(normalized_user_name);
CREATE UNIQUE INDEX idx_users_normalized_email ON users(normalized_email);
CREATE UNIQUE INDEX idx_roles_normalized_name ON roles(normalized_name);
-- OpenIddict (EF Core default tables)
CREATE TABLE "OpenIddictApplications" (
"Id" UUID PRIMARY KEY,
"ApplicationType" TEXT,
"ClientId" TEXT,
"ClientSecret" TEXT,
"ConsentType" TEXT,
"DisplayName" TEXT,
"DisplayNames" JSONB,
"Permissions" JSONB,
"PostLogoutRedirectUris" JSONB,
"Properties" JSONB,
"RedirectUris" JSONB,
"Requirements" JSONB,
"ConcurrencyToken" TEXT
);
CREATE TABLE oauth_codes (
id UUID PRIMARY KEY,
client_id UUID NOT NULL REFERENCES oauth_clients(id),
user_id UUID NOT NULL REFERENCES users(id),
code_hash TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
CREATE TABLE "OpenIddictAuthorizations" (
"Id" UUID PRIMARY KEY,
"ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
"CreationDate" TIMESTAMPTZ,
"Status" TEXT,
"Subject" TEXT,
"Type" TEXT,
"Scopes" JSONB,
"Properties" JSONB,
"ConcurrencyToken" TEXT
);
CREATE TABLE oauth_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
client_id UUID NOT NULL REFERENCES oauth_clients(id),
access_token_hash TEXT NOT NULL,
refresh_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
CREATE TABLE "OpenIddictScopes" (
"Id" UUID PRIMARY KEY,
"Description" TEXT,
"Descriptions" JSONB,
"DisplayName" TEXT,
"DisplayNames" JSONB,
"Name" TEXT,
"Properties" JSONB,
"Resources" JSONB,
"ConcurrencyToken" TEXT
);
CREATE TABLE oidc_id_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
client_id UUID NOT NULL REFERENCES oauth_clients(id),
id_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
CREATE TABLE "OpenIddictTokens" (
"Id" UUID PRIMARY KEY,
"ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
"AuthorizationId" UUID REFERENCES "OpenIddictAuthorizations"("Id") ON DELETE SET NULL,
"CreationDate" TIMESTAMPTZ,
"ExpirationDate" TIMESTAMPTZ,
"RedemptionDate" TIMESTAMPTZ,
"Payload" TEXT,
"ReferenceId" TEXT,
"Status" TEXT,
"Subject" TEXT,
"Type" TEXT,
"Properties" JSONB,
"ConcurrencyToken" TEXT
);
CREATE UNIQUE INDEX idx_openiddict_applications_client_id ON "OpenIddictApplications"("ClientId");
CREATE UNIQUE INDEX idx_openiddict_scopes_name ON "OpenIddictScopes"("Name");
CREATE UNIQUE INDEX idx_openiddict_tokens_reference_id ON "OpenIddictTokens"("ReferenceId");
CREATE INDEX idx_openiddict_tokens_subject ON "OpenIddictTokens"("Subject");
CREATE TABLE newsletter_lists (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -73,9 +151,9 @@ CREATE TABLE newsletter_lists (
CREATE TABLE newsletter_subscriptions (
id UUID PRIMARY KEY,
list_id UUID NOT NULL REFERENCES newsletter_lists(id),
list_id UUID NOT NULL REFERENCES newsletter_lists(id) ON DELETE CASCADE,
email TEXT NOT NULL,
user_id UUID REFERENCES users(id),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -85,7 +163,7 @@ CREATE TABLE newsletter_subscriptions (
CREATE TABLE email_verifications (
id UUID PRIMARY KEY,
email TEXT NOT NULL,
tenant_id UUID NOT NULL REFERENCES tenants(id),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
@ -94,7 +172,7 @@ CREATE TABLE email_verifications (
CREATE TABLE unsubscribe_tokens (
id UUID PRIMARY KEY,
subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id),
subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
@ -109,17 +187,11 @@ CREATE TABLE audit_logs (
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE roles (
CREATE TABLE system_flags (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id),
role_id UUID NOT NULL REFERENCES roles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id)
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_newsletter_subscriptions_email
@ -128,17 +200,5 @@ CREATE INDEX idx_newsletter_subscriptions_email
CREATE INDEX idx_newsletter_subscriptions_list_id
ON newsletter_subscriptions(list_id);
CREATE INDEX idx_oauth_tokens_user_id
ON oauth_tokens(user_id);
CREATE INDEX idx_oauth_tokens_client_id
ON oauth_tokens(client_id);
CREATE INDEX idx_oauth_codes_client_id
ON oauth_codes(client_id);
CREATE INDEX idx_email_verifications_email
ON email_verifications(email);
CREATE INDEX idx_user_roles_role_id
ON user_roles(role_id);

View File

@ -13,6 +13,7 @@ paths:
get:
summary: OAuth2 Authorization Endpoint
description: Authorization Code + PKCE flow
security: []
parameters:
- in: query
name: client_id
@ -49,20 +50,15 @@ paths:
/oauth/token:
post:
summary: OAuth2 Token Endpoint
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [grant_type, code, redirect_uri, client_id, code_verifier]
properties:
grant_type: { type: string, enum: [authorization_code, refresh_token] }
code: { type: string }
redirect_uri: { type: string }
client_id: { type: string }
code_verifier: { type: string }
refresh_token: { type: string }
oneOf:
- $ref: '#/components/schemas/AuthorizationCodeTokenRequest'
- $ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: Token response
@ -88,6 +84,7 @@ paths:
/auth/register:
post:
summary: Register user
security: []
requestBody:
required: true
content:
@ -105,12 +102,13 @@ paths:
/auth/login:
post:
summary: API login
security: []
requestBody:
required: true
content:
application/json:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/LoginRequest'
$ref: '#/components/schemas/PasswordTokenRequest'
responses:
'200':
description: Token response
@ -122,12 +120,13 @@ paths:
/auth/refresh:
post:
summary: Refresh token
security: []
requestBody:
required: true
content:
application/json:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/RefreshRequest'
$ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: Token response
@ -157,6 +156,7 @@ paths:
/auth/password/forgot:
post:
summary: Request password reset
security: []
requestBody:
required: true
content:
@ -170,6 +170,7 @@ paths:
/auth/password/reset:
post:
summary: Reset password
security: []
requestBody:
required: true
content:
@ -183,11 +184,16 @@ paths:
/auth/email/verify:
get:
summary: Verify email
security: []
parameters:
- in: query
name: token
required: true
schema: { type: string }
- in: query
name: email
required: true
schema: { type: string, format: email }
responses:
'200':
description: Email verified
@ -208,6 +214,7 @@ paths:
/newsletter/subscribe:
post:
summary: Subscribe (unauthenticated allowed)
security: []
requestBody:
required: true
content:
@ -220,11 +227,12 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Subscription'
$ref: '#/components/schemas/PendingSubscriptionResponse'
/newsletter/confirm:
get:
summary: Confirm subscription (double opt-in)
security: []
parameters:
- in: query
name: token
@ -241,6 +249,7 @@ paths:
/newsletter/unsubscribe:
post:
summary: Unsubscribe single list
security: []
requestBody:
required: true
content:
@ -266,10 +275,6 @@ paths:
name: subscription_id
required: false
schema: { type: string }
- in: query
name: tenant_id
required: false
schema: { type: string }
- in: query
name: email
required: false
@ -519,14 +524,25 @@ components:
email: { type: string, format: email }
password: { type: string }
LoginRequest:
PasswordTokenRequest:
type: object
required: [email, password, client_id]
required: [grant_type, username, password]
properties:
email: { type: string, format: email }
grant_type: { type: string, enum: [password] }
username: { type: string, format: email }
password: { type: string }
client_id: { type: string }
scope: { type: string }
client_id: { type: string }
AuthorizationCodeTokenRequest:
type: object
required: [grant_type, code, redirect_uri, code_verifier]
properties:
grant_type: { type: string, enum: [authorization_code] }
code: { type: string }
redirect_uri: { type: string }
code_verifier: { type: string }
client_id: { type: string }
RefreshRequest:
type: object
@ -535,6 +551,14 @@ components:
refresh_token: { type: string }
client_id: { type: string }
RefreshTokenRequest:
type: object
required: [grant_type, refresh_token]
properties:
grant_type: { type: string, enum: [refresh_token] }
refresh_token: { type: string }
client_id: { type: string }
ForgotPasswordRequest:
type: object
required: [email]
@ -543,8 +567,9 @@ components:
ResetPasswordRequest:
type: object
required: [token, new_password]
required: [email, token, new_password]
properties:
email: { type: string, format: email }
token: { type: string }
new_password: { type: string }
@ -558,9 +583,8 @@ components:
SubscribeRequest:
type: object
required: [tenant_id, list_id, email]
required: [list_id, email]
properties:
tenant_id: { type: string }
list_id: { type: string }
email: { type: string, format: email }
preferences: { type: object }
@ -570,13 +594,19 @@ components:
type: object
properties:
id: { type: string }
tenant_id: { type: string }
list_id: { type: string }
email: { type: string, format: email }
status: { type: string, enum: [pending, active, unsubscribed] }
preferences: { type: object }
created_at: { type: string, format: date-time }
PendingSubscriptionResponse:
allOf:
- $ref: '#/components/schemas/Subscription'
- type: object
properties:
confirm_token: { type: string }
Tenant:
type: object
properties:

View File

@ -0,0 +1,7 @@
namespace MemberCenter.Api.Contracts;
public sealed record TenantRequest(string Name, List<string> Domains, string Status);
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
public sealed record OAuthClientRequest(Guid TenantId, string Name, List<string> RedirectUris, string ClientType);

View File

@ -0,0 +1,13 @@
namespace MemberCenter.Api.Contracts;
public sealed record RegisterRequest(string Email, string Password);
public sealed record LoginRequest(string Email, string Password, string? Scope);
public sealed record RefreshRequest(string RefreshToken);
public sealed record ForgotPasswordRequest(string Email);
public sealed record ResetPasswordRequest(string Email, string Token, string NewPassword);
public sealed record LogoutRequest(string RefreshToken);

View File

@ -0,0 +1,7 @@
namespace MemberCenter.Api.Contracts;
public sealed record SubscribeRequest(Guid ListId, string Email, Dictionary<string, object>? Preferences, string? Source);
public sealed record UnsubscribeRequest(string Token);
public sealed record UpdatePreferencesRequest(Guid SubscriptionId, Dictionary<string, object> Preferences);

View File

@ -0,0 +1,112 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("admin/newsletter-lists")]
[Authorize(Policy = "Admin")]
public class AdminNewsletterListsController : ControllerBase
{
private readonly MemberCenterDbContext _dbContext;
public AdminNewsletterListsController(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> List()
{
var lists = await _dbContext.NewsletterLists.ToListAsync();
return Ok(lists.Select(l => new
{
l.Id,
l.TenantId,
l.Name,
l.Status
}));
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] NewsletterListRequest request)
{
var list = new NewsletterList
{
Id = Guid.NewGuid(),
TenantId = request.TenantId,
Name = request.Name,
Status = request.Status
};
_dbContext.NewsletterLists.Add(list);
await _dbContext.SaveChangesAsync();
return Created($"/admin/newsletter-lists/{list.Id}", new
{
list.Id,
list.TenantId,
list.Name,
list.Status
});
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var list = await _dbContext.NewsletterLists.FindAsync(id);
if (list is null)
{
return NotFound();
}
return Ok(new
{
list.Id,
list.TenantId,
list.Name,
list.Status
});
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] NewsletterListRequest request)
{
var list = await _dbContext.NewsletterLists.FindAsync(id);
if (list is null)
{
return NotFound();
}
list.TenantId = request.TenantId;
list.Name = request.Name;
list.Status = request.Status;
await _dbContext.SaveChangesAsync();
return Ok(new
{
list.Id,
list.TenantId,
list.Name,
list.Status
});
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
var list = await _dbContext.NewsletterLists.FindAsync(id);
if (list is null)
{
return NotFound();
}
_dbContext.NewsletterLists.Remove(list);
await _dbContext.SaveChangesAsync();
return NoContent();
}
}

View File

@ -0,0 +1,144 @@
using MemberCenter.Api.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using System.Text.Json;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("admin/oauth-clients")]
[Authorize(Policy = "Admin")]
public class AdminOAuthClientsController : ControllerBase
{
private readonly IOpenIddictApplicationManager _applicationManager;
public AdminOAuthClientsController(IOpenIddictApplicationManager applicationManager)
{
_applicationManager = applicationManager;
}
[HttpGet]
public async Task<IActionResult> List()
{
var results = new List<object>();
await foreach (var application in _applicationManager.ListAsync())
{
results.Add(new
{
id = await _applicationManager.GetIdAsync(application),
name = await _applicationManager.GetDisplayNameAsync(application),
client_id = await _applicationManager.GetClientIdAsync(application),
client_type = await _applicationManager.GetClientTypeAsync(application),
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application),
properties = await _applicationManager.GetPropertiesAsync(application)
});
}
return Ok(results);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
{
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Guid.NewGuid().ToString("N"),
DisplayName = request.Name,
ClientType = request.ClientType,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Profile,
"scp:openid"
}
};
foreach (var uri in request.RedirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
await _applicationManager.CreateAsync(descriptor);
return Created("/admin/oauth-clients", new
{
descriptor.ClientId,
descriptor.DisplayName,
descriptor.ClientType,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id)
{
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
return Ok(new
{
id,
name = await _applicationManager.GetDisplayNameAsync(app),
client_id = await _applicationManager.GetClientIdAsync(app),
client_type = await _applicationManager.GetClientTypeAsync(app),
redirect_uris = await _applicationManager.GetRedirectUrisAsync(app),
properties = await _applicationManager.GetPropertiesAsync(app)
});
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request)
{
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
var descriptor = new OpenIddictApplicationDescriptor();
await _applicationManager.PopulateAsync(descriptor, app);
descriptor.DisplayName = request.Name;
descriptor.ClientType = request.ClientType;
descriptor.RedirectUris.Clear();
foreach (var uri in request.RedirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
await _applicationManager.UpdateAsync(app, descriptor);
return Ok(new
{
id,
descriptor.DisplayName,
descriptor.ClientType,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string id)
{
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
await _applicationManager.DeleteAsync(app);
return NoContent();
}
}

View File

@ -0,0 +1,112 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("admin/tenants")]
[Authorize(Policy = "Admin")]
public class AdminTenantsController : ControllerBase
{
private readonly MemberCenterDbContext _dbContext;
public AdminTenantsController(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> List()
{
var tenants = await _dbContext.Tenants.ToListAsync();
return Ok(tenants.Select(t => new
{
t.Id,
t.Name,
t.Domains,
t.Status
}));
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] TenantRequest request)
{
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Name = request.Name,
Domains = request.Domains,
Status = request.Status
};
_dbContext.Tenants.Add(tenant);
await _dbContext.SaveChangesAsync();
return Created($"/admin/tenants/{tenant.Id}", new
{
tenant.Id,
tenant.Name,
tenant.Domains,
tenant.Status
});
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
if (tenant is null)
{
return NotFound();
}
return Ok(new
{
tenant.Id,
tenant.Name,
tenant.Domains,
tenant.Status
});
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] TenantRequest request)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
if (tenant is null)
{
return NotFound();
}
tenant.Name = request.Name;
tenant.Domains = request.Domains;
tenant.Status = request.Status;
await _dbContext.SaveChangesAsync();
return Ok(new
{
tenant.Id,
tenant.Name,
tenant.Domains,
tenant.Status
});
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
if (tenant is null)
{
return NotFound();
}
_dbContext.Tenants.Remove(tenant);
await _dbContext.SaveChangesAsync();
return NoContent();
}
}

View File

@ -0,0 +1,104 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AuthController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = request.Email,
Email = request.Email,
EmailConfirmed = false
};
var result = await _userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
return BadRequest(result.Errors.Select(e => e.Description));
}
return Ok(new
{
id = user.Id,
email = user.Email,
email_verified = user.EmailConfirmed,
created_at = user.CreatedAt
});
}
[HttpPost("password/forgot")]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null)
{
return NoContent();
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
return Ok(new { token });
}
[HttpPost("password/reset")]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null)
{
return BadRequest("Invalid user.");
}
var result = await _userManager.ResetPasswordAsync(user, request.Token, request.NewPassword);
if (!result.Succeeded)
{
return BadRequest(result.Errors.Select(e => e.Description));
}
return NoContent();
}
[HttpGet("email/verify")]
public async Task<IActionResult> VerifyEmail([FromQuery] string token, [FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return BadRequest("Invalid user.");
}
var result = await _userManager.ConfirmEmailAsync(user, token);
if (!result.Succeeded)
{
return BadRequest(result.Errors.Select(e => e.Description));
}
return Ok(new { status = "verified" });
}
[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
{
await _signInManager.SignOutAsync();
return NoContent();
}
}

View File

@ -0,0 +1,211 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Constants;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("newsletter")]
public class NewsletterController : ControllerBase
{
private readonly MemberCenterDbContext _dbContext;
public NewsletterController(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
[HttpPost("subscribe")]
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
{
var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == request.ListId);
if (list is null)
{
return NotFound("List not found.");
}
var subscription = await _dbContext.NewsletterSubscriptions
.FirstOrDefaultAsync(s => s.ListId == request.ListId && s.Email == request.Email);
if (subscription is null)
{
subscription = new NewsletterSubscription
{
Id = Guid.NewGuid(),
ListId = request.ListId,
Email = request.Email,
Status = SubscriptionStatus.Pending,
Preferences = ToJsonDocument(request.Preferences)
};
_dbContext.NewsletterSubscriptions.Add(subscription);
}
else
{
subscription.Status = SubscriptionStatus.Pending;
subscription.Preferences = ToJsonDocument(request.Preferences);
}
var confirmToken = CreateToken();
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(confirmToken),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
});
await _dbContext.SaveChangesAsync();
return Ok(new
{
id = subscription.Id,
list_id = subscription.ListId,
email = subscription.Email,
status = subscription.Status,
created_at = subscription.CreatedAt,
confirm_token = confirmToken
});
}
[HttpGet("confirm")]
public async Task<IActionResult> Confirm([FromQuery] string token)
{
var tokenHash = HashToken(token);
var confirmToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
if (confirmToken?.Subscription is null)
{
return NotFound("Invalid token.");
}
if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return BadRequest("Token expired.");
}
confirmToken.Subscription.Status = SubscriptionStatus.Active;
confirmToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return Ok(new
{
id = confirmToken.Subscription.Id,
list_id = confirmToken.Subscription.ListId,
email = confirmToken.Subscription.Email,
status = confirmToken.Subscription.Status,
created_at = confirmToken.Subscription.CreatedAt
});
}
[HttpPost("unsubscribe")]
public async Task<IActionResult> Unsubscribe([FromBody] UnsubscribeRequest request)
{
var tokenHash = HashToken(request.Token);
var unsubscribeToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
if (unsubscribeToken?.Subscription is null)
{
return NotFound("Invalid token.");
}
unsubscribeToken.Subscription.Status = SubscriptionStatus.Unsubscribed;
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return Ok(new
{
id = unsubscribeToken.Subscription.Id,
list_id = unsubscribeToken.Subscription.ListId,
email = unsubscribeToken.Subscription.Email,
status = unsubscribeToken.Subscription.Status,
created_at = unsubscribeToken.Subscription.CreatedAt
});
}
[HttpGet("preferences")]
public async Task<IActionResult> Preferences([FromQuery] Guid? subscriptionId, [FromQuery] string? email)
{
NewsletterSubscription? subscription = null;
if (subscriptionId.HasValue)
{
subscription = await _dbContext.NewsletterSubscriptions.FindAsync(subscriptionId.Value);
}
else if (!string.IsNullOrWhiteSpace(email))
{
subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
if (subscription is null)
{
return NotFound("Subscription not found.");
}
return Ok(new
{
id = subscription.Id,
list_id = subscription.ListId,
email = subscription.Email,
status = subscription.Status,
preferences = subscription.Preferences.RootElement
});
}
[HttpPost("preferences")]
public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest request)
{
var subscription = await _dbContext.NewsletterSubscriptions.FindAsync(request.SubscriptionId);
if (subscription is null)
{
return NotFound("Subscription not found.");
}
subscription.Preferences = ToJsonDocument(request.Preferences);
await _dbContext.SaveChangesAsync();
return Ok(new
{
id = subscription.Id,
list_id = subscription.ListId,
email = subscription.Email,
status = subscription.Status,
preferences = subscription.Preferences.RootElement
});
}
private static string CreateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes);
}
private static string HashToken(string token)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static JsonDocument ToJsonDocument(Dictionary<string, object>? value)
{
if (value is null)
{
return JsonDocument.Parse("{}");
}
return JsonDocument.Parse(JsonSerializer.Serialize(value));
}
}

View File

@ -0,0 +1,49 @@
using MemberCenter.Api.Extensions;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;
namespace MemberCenter.Api.Controllers;
[ApiController]
public class OAuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public OAuthController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
[HttpGet("/oauth/authorize")]
[Authorize]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request;
if (request is null)
{
return BadRequest("Invalid OpenIddict request.");
}
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = await _signInManager.CreateUserPrincipalAsync(user);
principal.SetScopes(request.GetScopes());
foreach (var claim in principal.Claims)
{
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
}

View File

@ -0,0 +1,74 @@
using MemberCenter.Api.Extensions;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
namespace MemberCenter.Api.Controllers;
[ApiController]
public class TokenController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public TokenController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
[HttpPost("/oauth/token")]
[HttpPost("/auth/login")]
[HttpPost("/auth/refresh")]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request;
if (request is null)
{
return BadRequest("Invalid OpenIddict request.");
}
if (request.IsPasswordGrantType())
{
var user = await _userManager.FindByEmailAsync(request.Username ?? string.Empty);
if (user is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var valid = await _userManager.CheckPasswordAsync(user, request.Password ?? string.Empty);
if (!valid)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = await _signInManager.CreateUserPrincipalAsync(user);
var scopes = request.Scope.GetScopesOrDefault();
principal.SetScopes(scopes);
foreach (var claim in principal.Claims)
{
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsRefreshTokenGrantType())
{
var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
if (!authenticateResult.Succeeded || authenticateResult.Principal is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = authenticateResult.Principal;
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
return BadRequest("Unsupported grant type.");
}
}

View File

@ -0,0 +1,37 @@
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("user")]
public class UserController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
public UserController(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
[Authorize]
[HttpGet("profile")]
public async Task<IActionResult> Profile()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
return Ok(new
{
id = user.Id,
email = user.Email,
email_verified = user.EmailConfirmed,
created_at = user.CreatedAt
});
}
}

View File

@ -0,0 +1,31 @@
using OpenIddict.Abstractions;
namespace MemberCenter.Api.Extensions;
public static class ClaimsExtensions
{
public static IEnumerable<string> GetScopesOrDefault(this string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return new[]
{
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile
};
}
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
public static IEnumerable<string> GetDestinations(System.Security.Claims.Claim claim)
{
return claim.Type switch
{
OpenIddictConstants.Claims.Name or OpenIddictConstants.Claims.Email =>
new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
};
}
}

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\MemberCenter.Application\MemberCenter.Application.csproj" />
<ProjectReference Include="..\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj" />
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.AspNetCore" Version="5.7.0" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="5.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,93 @@
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
options.UseOpenIddict();
});
builder.Services
.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
});
builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<MemberCenterDbContext>()
.ReplaceDefaultEntities<Guid>();
})
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("/oauth/authorize");
options.SetTokenEndpointUris("/oauth/token", "/auth/login", "/auth/refresh");
options.SetLogoutEndpointUris("/auth/logout");
options.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange();
options.AllowRefreshTokenFlow();
options.AllowPasswordFlow();
options.AcceptAnonymousClients();
options.RegisterScopes(
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile);
options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate();
options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableLogoutEndpointPassthrough()
.EnableStatusCodePagesIntegration();
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy => policy.RequireRole("admin"));
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:0",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:0;http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Database=member_center;Username=postgres;Password=postgres"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace MemberCenter.Domain.Constants;
public static class SubscriptionStatus
{
public const string Pending = "pending";
public const string Active = "active";
public const string Unsubscribed = "unsubscribed";
}

View File

@ -0,0 +1,11 @@
namespace MemberCenter.Domain.Entities;
public sealed class AuditLog
{
public Guid Id { get; set; }
public string ActorType { get; set; } = string.Empty;
public Guid? ActorId { get; set; }
public string Action { get; set; } = string.Empty;
public System.Text.Json.JsonDocument Payload { get; set; } = System.Text.Json.JsonDocument.Parse("{}");
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -0,0 +1,12 @@
namespace MemberCenter.Domain.Entities;
public sealed class EmailVerification
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public Guid TenantId { get; set; }
public string TokenHash { get; set; } = string.Empty;
public string Purpose { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? ConsumedAt { get; set; }
}

View File

@ -0,0 +1,13 @@
namespace MemberCenter.Domain.Entities;
public sealed class NewsletterList
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Tenant? Tenant { get; set; }
public List<NewsletterSubscription> Subscriptions { get; set; } = new();
}

View File

@ -0,0 +1,16 @@
using MemberCenter.Domain.Constants;
namespace MemberCenter.Domain.Entities;
public sealed class NewsletterSubscription
{
public Guid Id { get; set; }
public Guid ListId { get; set; }
public string Email { get; set; } = string.Empty;
public Guid? UserId { get; set; }
public string Status { get; set; } = SubscriptionStatus.Pending;
public System.Text.Json.JsonDocument Preferences { get; set; } = System.Text.Json.JsonDocument.Parse("{}");
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public NewsletterList? List { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace MemberCenter.Domain.Entities;
public sealed class SystemFlag
{
public Guid Id { get; set; }
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -0,0 +1,12 @@
namespace MemberCenter.Domain.Entities;
public sealed class Tenant
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<string> Domains { get; set; } = new();
public string Status { get; set; } = "active";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<NewsletterList> NewsletterLists { get; set; } = new();
}

View File

@ -0,0 +1,12 @@
namespace MemberCenter.Domain.Entities;
public sealed class UnsubscribeToken
{
public Guid Id { get; set; }
public Guid SubscriptionId { get; set; }
public string TokenHash { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? ConsumedAt { get; set; }
public NewsletterSubscription? Subscription { get; set; }
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity;
namespace MemberCenter.Infrastructure.Identity;
public class ApplicationRole : IdentityRole<Guid>
{
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity;
namespace MemberCenter.Infrastructure.Identity;
public class ApplicationUser : IdentityUser<Guid>
{
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MemberCenter.Application\MemberCenter.Application.csproj" />
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,167 @@
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Persistence;
public class MemberCenterDbContext
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>
{
public MemberCenterDbContext(DbContextOptions<MemberCenterDbContext> options)
: base(options)
{
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>();
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<SystemFlag> SystemFlags => Set<SystemFlag>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.UseOpenIddict();
builder.Entity<Tenant>(entity =>
{
entity.ToTable("tenants");
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired();
entity.Property(x => x.Status).IsRequired().HasDefaultValue("active");
entity.Property(x => x.Domains).HasColumnType("text[]");
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
});
builder.Entity<NewsletterList>(entity =>
{
entity.ToTable("newsletter_lists");
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired();
entity.Property(x => x.Status).IsRequired().HasDefaultValue("active");
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.HasOne(x => x.Tenant)
.WithMany(t => t.NewsletterLists)
.HasForeignKey(x => x.TenantId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<NewsletterSubscription>(entity =>
{
entity.ToTable("newsletter_subscriptions");
entity.HasKey(x => x.Id);
entity.Property(x => x.Email).IsRequired();
entity.Property(x => x.Status).IsRequired().HasDefaultValue("pending");
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.Property(x => x.Preferences)
.HasColumnType("jsonb")
.HasConversion(
v => v.RootElement.GetRawText(),
v => System.Text.Json.JsonDocument.Parse(v, new System.Text.Json.JsonDocumentOptions()));
entity.HasIndex(x => x.Email).HasDatabaseName("idx_newsletter_subscriptions_email");
entity.HasIndex(x => x.ListId).HasDatabaseName("idx_newsletter_subscriptions_list_id");
entity.HasIndex(x => new { x.ListId, x.Email }).IsUnique();
entity.HasOne(x => x.List)
.WithMany(l => l.Subscriptions)
.HasForeignKey(x => x.ListId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<EmailVerification>(entity =>
{
entity.ToTable("email_verifications");
entity.HasKey(x => x.Id);
entity.Property(x => x.Email).IsRequired();
entity.Property(x => x.TokenHash).IsRequired();
entity.Property(x => x.Purpose).IsRequired();
entity.HasIndex(x => x.Email).HasDatabaseName("idx_email_verifications_email");
entity.HasOne<Tenant>()
.WithMany()
.HasForeignKey(x => x.TenantId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UnsubscribeToken>(entity =>
{
entity.ToTable("unsubscribe_tokens");
entity.HasKey(x => x.Id);
entity.Property(x => x.TokenHash).IsRequired();
entity.HasOne(x => x.Subscription)
.WithMany()
.HasForeignKey(x => x.SubscriptionId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AuditLog>(entity =>
{
entity.ToTable("audit_logs");
entity.HasKey(x => x.Id);
entity.Property(x => x.ActorType).IsRequired();
entity.Property(x => x.Action).IsRequired();
entity.Property(x => x.Payload)
.HasColumnType("jsonb")
.HasConversion(
v => v.RootElement.GetRawText(),
v => System.Text.Json.JsonDocument.Parse(v, new System.Text.Json.JsonDocumentOptions()));
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
});
builder.Entity<SystemFlag>(entity =>
{
entity.ToTable("system_flags");
entity.HasKey(x => x.Id);
entity.Property(x => x.Key).IsRequired();
entity.Property(x => x.Value).IsRequired();
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<ApplicationUser>(entity =>
{
entity.ToTable("users");
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
});
builder.Entity<ApplicationRole>(entity =>
{
entity.ToTable("roles");
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserRole<Guid>>(entity =>
{
entity.ToTable("user_roles");
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserClaim<Guid>>(entity =>
{
entity.ToTable("user_claims");
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityRoleClaim<Guid>>(entity =>
{
entity.ToTable("role_claims");
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserLogin<Guid>>(entity =>
{
entity.ToTable("user_logins");
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserToken<Guid>>(entity =>
{
entity.ToTable("user_tokens");
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserLogin<Guid>>().HasKey(l => new { l.LoginProvider, l.ProviderKey });
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserToken<Guid>>().HasKey(t => new { t.UserId, t.LoginProvider, t.Name });
}
}

View File

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace MemberCenter.Infrastructure.Persistence;
public class MemberCenterDbContextFactory : IDesignTimeDbContextFactory<MemberCenterDbContext>
{
public MemberCenterDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("MEMBERCENTER_CONNECTION")
?? Environment.GetEnvironmentVariable("ConnectionStrings__Default")
?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
var optionsBuilder = new DbContextOptionsBuilder<MemberCenterDbContext>();
optionsBuilder.UseNpgsql(connectionString);
return new MemberCenterDbContext(optionsBuilder.Options);
}
}

View File

@ -0,0 +1,817 @@
// <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("20260203055037_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
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.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.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.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.Infrastructure.Identity.ApplicationRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.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<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>("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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
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", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.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")
.IsConcurrencyToken()
.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", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Scopes")
.HasColumnType("text");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictAuthorizations", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.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", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("AuthorizationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<DateTime?>("RedemptionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReferenceId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("AuthorizationId");
b.HasIndex("ReferenceId")
.IsUnique();
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictTokens", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b =>
{
b.HasOne("MemberCenter.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Authorizations")
.HasForeignKey("ApplicationId");
b.Navigation("Application");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Tokens")
.HasForeignKey("ApplicationId");
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization")
.WithMany("Tokens")
.HasForeignKey("AuthorizationId");
b.Navigation("Application");
b.Navigation("Authorization");
});
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
{
b.Navigation("Subscriptions");
});
modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b =>
{
b.Navigation("NewsletterLists");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");
b.Navigation("Tokens");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Navigation("Tokens");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,580 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "audit_logs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ActorType = table.Column<string>(type: "text", nullable: false),
ActorId = table.Column<Guid>(type: "uuid", nullable: true),
Action = table.Column<string>(type: "text", nullable: false),
Payload = table.Column<string>(type: "jsonb", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_audit_logs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictApplications",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ClientId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ClientSecret = table.Column<string>(type: "text", nullable: true),
ClientType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ConsentType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
DisplayName = table.Column<string>(type: "text", nullable: true),
DisplayNames = table.Column<string>(type: "text", nullable: true),
JsonWebKeySet = table.Column<string>(type: "text", nullable: true),
Permissions = table.Column<string>(type: "text", nullable: true),
PostLogoutRedirectUris = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
RedirectUris = table.Column<string>(type: "text", nullable: true),
Requirements = table.Column<string>(type: "text", nullable: true),
Settings = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictApplications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictScopes",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Descriptions = table.Column<string>(type: "text", nullable: true),
DisplayName = table.Column<string>(type: "text", nullable: true),
DisplayNames = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
Resources = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictScopes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "roles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "system_flags",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_system_flags", x => x.Id);
});
migrationBuilder.CreateTable(
name: "tenants",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Domains = table.Column<List<string>>(type: "text[]", nullable: false),
Status = table.Column<string>(type: "text", nullable: false, defaultValue: "active"),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_tenants", x => x.Id);
});
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictAuthorizations",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationId = table.Column<string>(type: "text", nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
Scopes = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id);
table.ForeignKey(
name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~",
column: x => x.ApplicationId,
principalTable: "OpenIddictApplications",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "role_claims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_role_claims", x => x.Id);
table.ForeignKey(
name: "FK_role_claims_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "email_verifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
Purpose = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ConsumedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_email_verifications", x => x.Id);
table.ForeignKey(
name: "FK_email_verifications_tenants_TenantId",
column: x => x.TenantId,
principalTable: "tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "newsletter_lists",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "text", nullable: false, defaultValue: "active"),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_newsletter_lists", x => x.Id);
table.ForeignKey(
name: "FK_newsletter_lists_tenants_TenantId",
column: x => x.TenantId,
principalTable: "tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_claims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_user_claims", x => x.Id);
table.ForeignKey(
name: "FK_user_claims_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_logins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_logins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_user_logins_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_roles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_roles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_user_roles_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_user_roles_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_tokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_user_tokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_user_tokens_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OpenIddictTokens",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationId = table.Column<string>(type: "text", nullable: true),
AuthorizationId = table.Column<string>(type: "text", nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ExpirationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Payload = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
RedemptionDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ReferenceId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictTokens", x => x.Id);
table.ForeignKey(
name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId",
column: x => x.ApplicationId,
principalTable: "OpenIddictApplications",
principalColumn: "Id");
table.ForeignKey(
name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId",
column: x => x.AuthorizationId,
principalTable: "OpenIddictAuthorizations",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "newsletter_subscriptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ListId = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: true),
Status = table.Column<string>(type: "text", nullable: false, defaultValue: "pending"),
Preferences = table.Column<string>(type: "jsonb", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_newsletter_subscriptions", x => x.Id);
table.ForeignKey(
name: "FK_newsletter_subscriptions_newsletter_lists_ListId",
column: x => x.ListId,
principalTable: "newsletter_lists",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_newsletter_subscriptions_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "unsubscribe_tokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SubscriptionId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ConsumedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_unsubscribe_tokens", x => x.Id);
table.ForeignKey(
name: "FK_unsubscribe_tokens_newsletter_subscriptions_SubscriptionId",
column: x => x.SubscriptionId,
principalTable: "newsletter_subscriptions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_email_verifications_email",
table: "email_verifications",
column: "Email");
migrationBuilder.CreateIndex(
name: "IX_email_verifications_TenantId",
table: "email_verifications",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_newsletter_lists_TenantId",
table: "newsletter_lists",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "idx_newsletter_subscriptions_email",
table: "newsletter_subscriptions",
column: "Email");
migrationBuilder.CreateIndex(
name: "idx_newsletter_subscriptions_list_id",
table: "newsletter_subscriptions",
column: "ListId");
migrationBuilder.CreateIndex(
name: "IX_newsletter_subscriptions_ListId_Email",
table: "newsletter_subscriptions",
columns: new[] { "ListId", "Email" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_newsletter_subscriptions_UserId",
table: "newsletter_subscriptions",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_OpenIddictApplications_ClientId",
table: "OpenIddictApplications",
column: "ClientId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type",
table: "OpenIddictAuthorizations",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
migrationBuilder.CreateIndex(
name: "IX_OpenIddictScopes_Name",
table: "OpenIddictScopes",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type",
table: "OpenIddictTokens",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_AuthorizationId",
table: "OpenIddictTokens",
column: "AuthorizationId");
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_ReferenceId",
table: "OpenIddictTokens",
column: "ReferenceId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_role_claims_RoleId",
table: "role_claims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "roles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_system_flags_Key",
table: "system_flags",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_unsubscribe_tokens_SubscriptionId",
table: "unsubscribe_tokens",
column: "SubscriptionId");
migrationBuilder.CreateIndex(
name: "IX_user_claims_UserId",
table: "user_claims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_user_logins_UserId",
table: "user_logins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_user_roles_RoleId",
table: "user_roles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "users",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "users",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "audit_logs");
migrationBuilder.DropTable(
name: "email_verifications");
migrationBuilder.DropTable(
name: "OpenIddictScopes");
migrationBuilder.DropTable(
name: "OpenIddictTokens");
migrationBuilder.DropTable(
name: "role_claims");
migrationBuilder.DropTable(
name: "system_flags");
migrationBuilder.DropTable(
name: "unsubscribe_tokens");
migrationBuilder.DropTable(
name: "user_claims");
migrationBuilder.DropTable(
name: "user_logins");
migrationBuilder.DropTable(
name: "user_roles");
migrationBuilder.DropTable(
name: "user_tokens");
migrationBuilder.DropTable(
name: "OpenIddictAuthorizations");
migrationBuilder.DropTable(
name: "newsletter_subscriptions");
migrationBuilder.DropTable(
name: "roles");
migrationBuilder.DropTable(
name: "OpenIddictApplications");
migrationBuilder.DropTable(
name: "newsletter_lists");
migrationBuilder.DropTable(
name: "users");
migrationBuilder.DropTable(
name: "tenants");
}
}
}

View File

@ -0,0 +1,814 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MemberCenterDbContext))]
partial class MemberCenterDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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.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.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.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.Infrastructure.Identity.ApplicationRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.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<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>("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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
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", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.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")
.IsConcurrencyToken()
.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", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Scopes")
.HasColumnType("text");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictAuthorizations", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.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", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("AuthorizationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<DateTime?>("RedemptionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReferenceId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("AuthorizationId");
b.HasIndex("ReferenceId")
.IsUnique();
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictTokens", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b =>
{
b.HasOne("MemberCenter.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Authorizations")
.HasForeignKey("ApplicationId");
b.Navigation("Application");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Tokens")
.HasForeignKey("ApplicationId");
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization")
.WithMany("Tokens")
.HasForeignKey("AuthorizationId");
b.Navigation("Application");
b.Navigation("Authorization");
});
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
{
b.Navigation("Subscriptions");
});
modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b =>
{
b.Navigation("NewsletterLists");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");
b.Navigation("Tokens");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Navigation("Tokens");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj" />
<ProjectReference Include="..\MemberCenter.Application\MemberCenter.Application.csproj" />
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,436 @@
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.CommandLine;
var root = new RootCommand("Member Center installer");
var connectionStringOption = new Option<string>(
name: "--connection-string",
description: "Database connection string");
connectionStringOption.AddAlias("-c");
var appsettingsOption = new Option<string?>(
name: "--appsettings",
description: "Path to appsettings.json");
var noPromptOption = new Option<bool>(
name: "--no-prompt",
description: "Disable interactive prompts");
var verboseOption = new Option<bool>(
name: "--verbose",
description: "Enable verbose output");
var forceOption = new Option<bool>(
name: "--force",
description: "Force execution even if already installed");
var adminEmailOption = new Option<string?>(
name: "--admin-email",
description: "Admin email");
var adminPasswordOption = new Option<string?>(
name: "--admin-password",
description: "Admin password");
var adminDisplayNameOption = new Option<string?>(
name: "--admin-display-name",
description: "Admin display name (optional)");
var targetMigrationOption = new Option<string?>(
name: "--target",
description: "Target migration");
var initCommand = new Command("init", "Initialize database (migrate + seed + admin)");
initCommand.AddOption(connectionStringOption);
initCommand.AddOption(appsettingsOption);
initCommand.AddOption(noPromptOption);
initCommand.AddOption(verboseOption);
initCommand.AddOption(forceOption);
initCommand.AddOption(adminEmailOption);
initCommand.AddOption(adminPasswordOption);
initCommand.AddOption(adminDisplayNameOption);
initCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, bool verbose, bool force, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
if (!force && await IsInstalledAsync(db))
{
Console.Error.WriteLine("System already installed. Use --force to override.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
return;
}
await db.Database.MigrateAsync();
await EnsureRoleAsync(roleManager, "admin");
await EnsureRoleAsync(roleManager, "support");
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true
};
var createResult = await userManager.CreateAsync(admin, adminPassword);
if (!createResult.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
return;
}
}
if (!await userManager.IsInRoleAsync(admin, "admin"))
{
await userManager.AddToRoleAsync(admin, "admin");
}
await SetInstalledFlagAsync(db);
if (verbose)
{
Console.WriteLine("Init completed.");
}
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
var addAdminCommand = new Command("add-admin", "Add admin user");
addAdminCommand.AddOption(connectionStringOption);
addAdminCommand.AddOption(appsettingsOption);
addAdminCommand.AddOption(noPromptOption);
addAdminCommand.AddOption(adminEmailOption);
addAdminCommand.AddOption(adminPasswordOption);
addAdminCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
await EnsureRoleAsync(roleManager, "admin");
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true
};
var createResult = await userManager.CreateAsync(admin, adminPassword);
if (!createResult.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
return;
}
}
if (!await userManager.IsInRoleAsync(admin, "admin"))
{
await userManager.AddToRoleAsync(admin, "admin");
}
Console.WriteLine("Admin user ready.");
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
var resetCommand = new Command("reset-admin-password", "Reset admin password");
resetCommand.AddOption(connectionStringOption);
resetCommand.AddOption(appsettingsOption);
resetCommand.AddOption(noPromptOption);
resetCommand.AddOption(adminEmailOption);
resetCommand.AddOption(adminPasswordOption);
resetCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("New password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
Console.Error.WriteLine("Admin not found.");
return;
}
var resetToken = await userManager.GeneratePasswordResetTokenAsync(admin);
var result = await userManager.ResetPasswordAsync(admin, resetToken, adminPassword);
if (!result.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
return;
}
Console.WriteLine("Password reset completed.");
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
var migrateCommand = new Command("migrate", "Run migrations only");
migrateCommand.AddOption(connectionStringOption);
migrateCommand.AddOption(appsettingsOption);
migrateCommand.AddOption(targetMigrationOption);
migrateCommand.SetHandler(async (string? connectionString, string? appsettings, string? target) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
if (string.IsNullOrWhiteSpace(target))
{
await db.Database.MigrateAsync();
}
else
{
var migrator = scope.ServiceProvider.GetRequiredService<IMigrator>();
await migrator.MigrateAsync(target);
}
Console.WriteLine("Migrations completed.");
}, connectionStringOption, appsettingsOption, targetMigrationOption);
root.AddCommand(initCommand);
root.AddCommand(addAdminCommand);
root.AddCommand(resetCommand);
root.AddCommand(migrateCommand);
return await root.InvokeAsync(args);
static IServiceProvider BuildServices(string connectionString)
{
var services = new ServiceCollection();
services.AddDbContext<MemberCenterDbContext>(options =>
{
options.UseNpgsql(connectionString);
options.UseOpenIddict();
});
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
return services.BuildServiceProvider();
}
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt)
{
var targetPath = string.IsNullOrWhiteSpace(appsettingsPath) ? "appsettings.json" : appsettingsPath;
if (!string.IsNullOrWhiteSpace(connectionString))
{
WriteConnectionString(targetPath, connectionString);
return connectionString;
}
if (File.Exists(targetPath))
{
var config = new ConfigurationBuilder()
.AddJsonFile(targetPath)
.Build();
var existing = config.GetConnectionString("Default");
if (!string.IsNullOrWhiteSpace(existing))
{
return existing;
}
}
if (noPrompt)
{
return null;
}
var host = Prompt("DB host", "localhost");
var database = Prompt("DB name", "member_center");
var username = Prompt("DB user", "postgres");
var password = PromptSecret("DB password");
var generated = $"Host={host};Database={database};Username={username};Password={password}";
WriteConnectionString(targetPath, generated);
return generated;
}
static void WriteConnectionString(string path, string connectionString)
{
JsonNode root;
if (File.Exists(path))
{
var json = File.ReadAllText(path);
root = JsonNode.Parse(json) ?? new JsonObject();
}
else
{
root = new JsonObject();
}
var obj = root as JsonObject ?? new JsonObject();
var connectionStrings = obj["ConnectionStrings"] as JsonObject ?? new JsonObject();
connectionStrings["Default"] = connectionString;
obj["ConnectionStrings"] = connectionStrings;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(path, obj.ToJsonString(options));
}
static async Task<bool> IsInstalledAsync(MemberCenterDbContext db)
{
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase);
}
static async Task SetInstalledFlagAsync(MemberCenterDbContext db)
{
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
if (flag is null)
{
db.SystemFlags.Add(new SystemFlag
{
Id = Guid.NewGuid(),
Key = "installed",
Value = "true",
UpdatedAt = DateTimeOffset.UtcNow
});
}
else
{
flag.Value = "true";
flag.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync();
}
static async Task EnsureRoleAsync(RoleManager<ApplicationRole> roleManager, string roleName)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
var result = await roleManager.CreateAsync(new ApplicationRole
{
Id = Guid.NewGuid(),
Name = roleName,
NormalizedName = roleName.ToUpperInvariant()
});
if (!result.Succeeded)
{
throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
}
}
}
static string Prompt(string label, string defaultValue)
{
Console.Write($"{label} [{defaultValue}]: ");
var input = Console.ReadLine();
return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim();
}
static string PromptSecret(string label)
{
Console.Write($"{label}: ");
var password = string.Empty;
ConsoleKeyInfo key;
while ((key = Console.ReadKey(true)).Key != ConsoleKey.Enter)
{
if (key.Key == ConsoleKey.Backspace && password.Length > 0)
{
password = password[..^1];
Console.Write("\b \b");
continue;
}
if (!char.IsControl(key.KeyChar))
{
password += key.KeyChar;
Console.Write("*");
}
}
Console.WriteLine();
return password;
}