diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..3dd2352
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "8.0.11",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index ca73059..f276d92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..55672f0
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+ false
+ false
+
+
diff --git a/MemberCenter.sln b/MemberCenter.sln
new file mode 100644
index 0000000..0052256
--- /dev/null
+++ b/MemberCenter.sln
@@ -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
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 64b51a1..da17300 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -266,10 +266,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
@@ -558,9 +554,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,7 +565,6 @@ 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] }
diff --git a/src/MemberCenter.Api/Contracts/AdminRequests.cs b/src/MemberCenter.Api/Contracts/AdminRequests.cs
new file mode 100644
index 0000000..29e54ed
--- /dev/null
+++ b/src/MemberCenter.Api/Contracts/AdminRequests.cs
@@ -0,0 +1,7 @@
+namespace MemberCenter.Api.Contracts;
+
+public sealed record TenantRequest(string Name, List Domains, string Status);
+
+public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
+
+public sealed record OAuthClientRequest(Guid TenantId, string Name, List RedirectUris, string ClientType);
diff --git a/src/MemberCenter.Api/Contracts/AuthRequests.cs b/src/MemberCenter.Api/Contracts/AuthRequests.cs
new file mode 100644
index 0000000..5daa459
--- /dev/null
+++ b/src/MemberCenter.Api/Contracts/AuthRequests.cs
@@ -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);
diff --git a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs
new file mode 100644
index 0000000..63687b8
--- /dev/null
+++ b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs
@@ -0,0 +1,7 @@
+namespace MemberCenter.Api.Contracts;
+
+public sealed record SubscribeRequest(Guid ListId, string Email, Dictionary? Preferences, string? Source);
+
+public sealed record UnsubscribeRequest(string Token);
+
+public sealed record UpdatePreferencesRequest(Guid SubscriptionId, Dictionary Preferences);
diff --git a/src/MemberCenter.Api/Controllers/AdminNewsletterListsController.cs b/src/MemberCenter.Api/Controllers/AdminNewsletterListsController.cs
new file mode 100644
index 0000000..86776c8
--- /dev/null
+++ b/src/MemberCenter.Api/Controllers/AdminNewsletterListsController.cs
@@ -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 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 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 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 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 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();
+ }
+}
diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs
new file mode 100644
index 0000000..50e7653
--- /dev/null
+++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs
@@ -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 List()
+ {
+ var results = new List