feat: Implement SendEngine database context and migrations

- Added SendEngineDbContext for managing database interactions.
- Created SendEngineDbContextFactory for design-time database context creation.
- Established dependency injection for the infrastructure layer.
- Defined entity configurations for Tenant, MailingList, Subscriber, ListMember, EventInbox, Campaign, SendJob, SendBatch, DeliverySummary, AuthClient, AuthClientKey, and WebhookNonce.
- Generated initial database migration snapshot.
- Implemented installer program for database migration commands.
This commit is contained in:
warrenchen 2026-02-10 17:56:29 +09:00
parent 5f749af298
commit e9712fb1f7
37 changed files with 2989 additions and 5 deletions

View File

@ -2,3 +2,10 @@ ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres
ESP__Provider=ses
ESP__ApiKey=change_me
Db__AutoMigrate=true
Jwt__Issuer=member_center
Jwt__Audience=send_engine
Jwt__SigningKey=change_me_jwt_signing_key
Webhook__Secrets__member_center=change_me_webhook_secret
Webhook__TimestampSkewSeconds=300
Ses__SkipSignatureValidation=true

View File

@ -50,6 +50,9 @@ mass_mail_engine/
└── README.md
```
## Build
使用 VS Code `Run Build Task`(預設執行 `dotnet build SendEngine.sln`)。
## 待確認事項
- 事件系統選擇Kafka/RabbitMQ/SNS+SQS / Webhook
- ESP 優先順序SES / SendGrid / Mailgun

View File

@ -1,4 +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

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", "{AD7B89A4-26BD-4FFB-B8AB-A3A03CE4AD03}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SendEngine.Api", "src\SendEngine.Api\SendEngine.Api.csproj", "{BBD2426B-0DA0-4711-80AA-04E93AAEEDA6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SendEngine.Domain", "src\SendEngine.Domain\SendEngine.Domain.csproj", "{B8EA34E2-E68E-436B-B559-BFB61C019A48}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SendEngine.Application", "src\SendEngine.Application\SendEngine.Application.csproj", "{192589C9-6D2D-4BEE-9D36-E45A8B50D844}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SendEngine.Infrastructure", "src\SendEngine.Infrastructure\SendEngine.Infrastructure.csproj", "{B0D8BA24-3F27-4CDB-8FC0-058F62BBCDF5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SendEngine.Installer", "src\SendEngine.Installer\SendEngine.Installer.csproj", "{449F726D-6D8D-4B8D-9474-A4AD9C840000}"
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
{BBD2426B-0DA0-4711-80AA-04E93AAEEDA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BBD2426B-0DA0-4711-80AA-04E93AAEEDA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BBD2426B-0DA0-4711-80AA-04E93AAEEDA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BBD2426B-0DA0-4711-80AA-04E93AAEEDA6}.Release|Any CPU.Build.0 = Release|Any CPU
{B8EA34E2-E68E-436B-B559-BFB61C019A48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8EA34E2-E68E-436B-B559-BFB61C019A48}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8EA34E2-E68E-436B-B559-BFB61C019A48}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8EA34E2-E68E-436B-B559-BFB61C019A48}.Release|Any CPU.Build.0 = Release|Any CPU
{192589C9-6D2D-4BEE-9D36-E45A8B50D844}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{192589C9-6D2D-4BEE-9D36-E45A8B50D844}.Debug|Any CPU.Build.0 = Debug|Any CPU
{192589C9-6D2D-4BEE-9D36-E45A8B50D844}.Release|Any CPU.ActiveCfg = Release|Any CPU
{192589C9-6D2D-4BEE-9D36-E45A8B50D844}.Release|Any CPU.Build.0 = Release|Any CPU
{B0D8BA24-3F27-4CDB-8FC0-058F62BBCDF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B0D8BA24-3F27-4CDB-8FC0-058F62BBCDF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B0D8BA24-3F27-4CDB-8FC0-058F62BBCDF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B0D8BA24-3F27-4CDB-8FC0-058F62BBCDF5}.Release|Any CPU.Build.0 = Release|Any CPU
{449F726D-6D8D-4B8D-9474-A4AD9C840000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{449F726D-6D8D-4B8D-9474-A4AD9C840000}.Debug|Any CPU.Build.0 = Debug|Any CPU
{449F726D-6D8D-4B8D-9474-A4AD9C840000}.Release|Any CPU.ActiveCfg = Release|Any CPU
{449F726D-6D8D-4B8D-9474-A4AD9C840000}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BBD2426B-0DA0-4711-80AA-04E93AAEEDA6} = {AD7B89A4-26BD-4FFB-B8AB-A3A03CE4AD03}
{B8EA34E2-E68E-436B-B559-BFB61C019A48} = {AD7B89A4-26BD-4FFB-B8AB-A3A03CE4AD03}
{192589C9-6D2D-4BEE-9D36-E45A8B50D844} = {AD7B89A4-26BD-4FFB-B8AB-A3A03CE4AD03}
{B0D8BA24-3F27-4CDB-8FC0-058F62BBCDF5} = {AD7B89A4-26BD-4FFB-B8AB-A3A03CE4AD03}
{449F726D-6D8D-4B8D-9474-A4AD9C840000} = {AD7B89A4-26BD-4FFB-B8AB-A3A03CE4AD03}
EndGlobalSection
EndGlobal

View File

@ -2,3 +2,7 @@
- 需求:.NET SDK 8.x, PostgreSQL
- 設定:複製 `.env.example``.env`
- Migration
- 預設由 API 啟動時自動執行(`Db__AutoMigrate=true`
- 需要關閉時請設定 `Db__AutoMigrate=false`
- 手動執行可用 `dotnet run --project src/SendEngine.Installer -- migrate`

View File

@ -22,11 +22,13 @@ Header 建議:
- `X-Signature`: `hex(hmac_sha256(secret, body))`
- `X-Timestamp`: Unix epoch seconds
- `X-Nonce`: UUID
- `X-Client-Id`: Auth client UUID對應 `auth_clients.id`
驗證規則:
- timestamp 在允許時間窗內(例如 ±5 分鐘)
- nonce 不可重複(重放防護)
- signature 必須匹配
- client 必須存在且為 active
### 3. Send Engine → Member Center 回寫
使用 OAuth2 Client CredentialsSend Engine 作為 client
@ -205,7 +207,7 @@ Endpoint
- `POST /webhooks/ses`
驗證:
- 依 SES/SNS 規格驗簽
- 依 SES/SNS 規格驗簽(可用 `Ses__SkipSignatureValidation=true` 暫時略過)
Request Body示意
```json

View File

@ -135,6 +135,7 @@ paths:
- webhookSignature: []
webhookTimestamp: []
webhookNonce: []
webhookClientId: []
requestBody:
required: true
content:
@ -176,6 +177,7 @@ paths:
- webhookSignature: []
webhookTimestamp: []
webhookNonce: []
webhookClientId: []
requestBody:
required: true
content:
@ -255,6 +257,10 @@ components:
type: apiKey
in: header
name: X-Nonce
webhookClientId:
type: apiKey
in: header
name: X-Client-Id
sesSignature:
type: apiKey
in: header

View File

@ -0,0 +1,48 @@
using System.Text.Json;
namespace SendEngine.Api.Models;
public sealed class CreateSendJobRequest
{
public Guid TenantId { get; set; }
public Guid ListId { get; set; }
public string? Name { get; set; }
public string? Subject { get; set; }
public string? BodyHtml { get; set; }
public string? BodyText { get; set; }
public JsonElement? Template { get; set; }
public DateTimeOffset? ScheduledAt { get; set; }
public DateTimeOffset? WindowStart { get; set; }
public DateTimeOffset? WindowEnd { get; set; }
public TrackingOptions? Tracking { get; set; }
}
public sealed class TrackingOptions
{
public bool? Open { get; set; }
public bool? Click { get; set; }
}
public sealed class CreateSendJobResponse
{
public Guid SendJobId { get; set; }
public string Status { get; set; } = "pending";
}
public sealed class SendJobResponse
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid ListId { get; set; }
public Guid CampaignId { get; set; }
public string Status { get; set; } = "pending";
public DateTimeOffset? ScheduledAt { get; set; }
public DateTimeOffset? WindowStart { get; set; }
public DateTimeOffset? WindowEnd { get; set; }
}
public sealed class SendJobStatusResponse
{
public Guid Id { get; set; }
public string Status { get; set; } = string.Empty;
}

View File

@ -0,0 +1,40 @@
namespace SendEngine.Api.Models;
public sealed class SubscriptionEventRequest
{
public Guid EventId { get; set; }
public string EventType { get; set; } = string.Empty;
public Guid TenantId { get; set; }
public Guid ListId { get; set; }
public SubscriberPayload Subscriber { get; set; } = new();
public DateTimeOffset OccurredAt { get; set; }
}
public sealed class SubscriberPayload
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public Dictionary<string, object>? Preferences { get; set; }
}
public sealed class FullSyncBatchRequest
{
public Guid SyncId { get; set; }
public int BatchNo { get; set; }
public int BatchTotal { get; set; }
public Guid TenantId { get; set; }
public Guid ListId { get; set; }
public List<SubscriberPayload> Subscribers { get; set; } = new();
public DateTimeOffset OccurredAt { get; set; }
}
public sealed class SesEventRequest
{
public string EventType { get; set; } = string.Empty;
public string MessageId { get; set; } = string.Empty;
public Guid TenantId { get; set; }
public string Email { get; set; } = string.Empty;
public string? BounceType { get; set; }
public DateTimeOffset OccurredAt { get; set; }
}

View File

@ -0,0 +1,286 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SendEngine.Api.Models;
using SendEngine.Api.Security;
using SendEngine.Domain.Entities;
using SendEngine.Infrastructure;
using SendEngine.Infrastructure.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddInfrastructure(builder.Configuration);
var signingKey = builder.Configuration["Jwt:SigningKey"];
if (string.IsNullOrWhiteSpace(signingKey))
{
throw new InvalidOperationException("Jwt:SigningKey is required.");
}
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
var autoMigrate = builder.Configuration.GetValue("Db:AutoMigrate", true);
if (autoMigrate)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
db.Database.Migrate();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }))
.WithName("Health")
.WithOpenApi();
app.MapPost("/api/send-jobs", async (HttpContext httpContext, CreateSendJobRequest request, SendEngineDbContext db) =>
{
var tenantId = GetTenantId(httpContext.User);
if (tenantId is null)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (request.TenantId == Guid.Empty)
{
request.TenantId = tenantId.Value;
}
else if (request.TenantId != tenantId.Value)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (request.ListId == Guid.Empty)
{
return Results.UnprocessableEntity(new { error = "list_id_required" });
}
if (string.IsNullOrWhiteSpace(request.Subject))
{
return Results.UnprocessableEntity(new { error = "subject_required" });
}
var hasContent = !string.IsNullOrWhiteSpace(request.BodyHtml)
|| !string.IsNullOrWhiteSpace(request.BodyText)
|| request.Template.HasValue;
if (!hasContent)
{
return Results.UnprocessableEntity(new { error = "content_required" });
}
if (request.WindowStart.HasValue && request.WindowEnd.HasValue
&& request.WindowStart.Value >= request.WindowEnd.Value)
{
return Results.UnprocessableEntity(new { error = "window_invalid" });
}
var campaign = new Campaign
{
Id = Guid.NewGuid(),
TenantId = request.TenantId,
ListId = request.ListId,
Name = request.Name,
Subject = request.Subject,
BodyHtml = request.BodyHtml,
BodyText = request.BodyText,
Template = request.Template.HasValue ? request.Template.Value.GetRawText() : null,
CreatedAt = DateTimeOffset.UtcNow
};
var sendJob = new SendJob
{
Id = Guid.NewGuid(),
TenantId = request.TenantId,
ListId = request.ListId,
CampaignId = campaign.Id,
ScheduledAt = request.ScheduledAt,
WindowStart = request.WindowStart,
WindowEnd = request.WindowEnd,
Status = "pending",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
db.Campaigns.Add(campaign);
db.SendJobs.Add(sendJob);
await db.SaveChangesAsync();
return Results.Ok(new CreateSendJobResponse
{
SendJobId = sendJob.Id,
Status = sendJob.Status
});
}).RequireAuthorization().WithName("CreateSendJob").WithOpenApi();
app.MapGet("/api/send-jobs/{id:guid}", async (HttpContext httpContext, Guid id, SendEngineDbContext db) =>
{
var tenantId = GetTenantId(httpContext.User);
if (tenantId is null)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var sendJob = await db.SendJobs.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId.Value);
if (sendJob is null)
{
return Results.NotFound();
}
return Results.Ok(new SendJobResponse
{
Id = sendJob.Id,
TenantId = sendJob.TenantId,
ListId = sendJob.ListId,
CampaignId = sendJob.CampaignId,
Status = sendJob.Status,
ScheduledAt = sendJob.ScheduledAt,
WindowStart = sendJob.WindowStart,
WindowEnd = sendJob.WindowEnd
});
}).RequireAuthorization().WithName("GetSendJob").WithOpenApi();
app.MapPost("/api/send-jobs/{id:guid}/cancel", async (HttpContext httpContext, Guid id, SendEngineDbContext db) =>
{
var tenantId = GetTenantId(httpContext.User);
if (tenantId is null)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var sendJob = await db.SendJobs.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId.Value);
if (sendJob is null)
{
return Results.NotFound();
}
sendJob.Status = "cancelled";
sendJob.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(new SendJobStatusResponse { Id = sendJob.Id, Status = sendJob.Status });
}).RequireAuthorization().WithName("CancelSendJob").WithOpenApi();
app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, SubscriptionEventRequest request, SendEngineDbContext db) =>
{
var secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty;
var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300);
var validation = await WebhookValidator.ValidateAsync(httpContext, db, secret, skewSeconds);
if (validation is not null)
{
return validation;
}
var payload = JsonSerializer.Serialize(request);
var inbox = new EventInbox
{
Id = request.EventId == Guid.Empty ? Guid.NewGuid() : request.EventId,
TenantId = request.TenantId,
EventType = request.EventType,
Source = "member_center",
Payload = payload,
ReceivedAt = DateTimeOffset.UtcNow,
Status = "received"
};
db.EventsInbox.Add(inbox);
await db.SaveChangesAsync();
return Results.Ok();
}).WithName("SubscriptionWebhook").WithOpenApi();
app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyncBatchRequest request, SendEngineDbContext db) =>
{
var secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty;
var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300);
var validation = await WebhookValidator.ValidateAsync(httpContext, db, secret, skewSeconds);
if (validation is not null)
{
return validation;
}
var payload = JsonSerializer.Serialize(request);
var inbox = new EventInbox
{
Id = Guid.NewGuid(),
TenantId = request.TenantId,
EventType = "list.full_sync",
Source = "member_center",
Payload = payload,
ReceivedAt = DateTimeOffset.UtcNow,
Status = "received"
};
db.EventsInbox.Add(inbox);
await db.SaveChangesAsync();
return Results.Ok();
}).WithName("FullSyncWebhook").WithOpenApi();
app.MapPost("/webhooks/ses", async (HttpContext httpContext, SesEventRequest request, SendEngineDbContext db) =>
{
var skipValidation = builder.Configuration.GetValue("Ses:SkipSignatureValidation", true);
var sesSignature = httpContext.Request.Headers["X-Amz-Sns-Signature"].ToString();
if (!skipValidation && string.IsNullOrWhiteSpace(sesSignature))
{
return Results.Unauthorized();
}
var payload = JsonSerializer.Serialize(request);
var inbox = new EventInbox
{
Id = Guid.NewGuid(),
TenantId = request.TenantId,
EventType = $"ses.{request.EventType}",
Source = "ses",
Payload = payload,
ReceivedAt = DateTimeOffset.UtcNow,
Status = "received"
};
db.EventsInbox.Add(inbox);
await db.SaveChangesAsync();
return Results.Ok();
}).WithName("SesWebhook").WithOpenApi();
app.Run();
static Guid? GetTenantId(ClaimsPrincipal user)
{
var value = user.FindFirst("tenant_id")?.Value;
return Guid.TryParse(value, out var tenantId) ? tenantId : null;
}

View File

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

View File

@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using SendEngine.Infrastructure.Data;
using SendEngine.Domain.Entities;
namespace SendEngine.Api.Security;
public static class WebhookValidator
{
public static async Task<IResult?> ValidateAsync(HttpContext context, SendEngineDbContext db, string secret, int maxSkewSeconds)
{
if (string.IsNullOrWhiteSpace(secret))
{
return Results.StatusCode(StatusCodes.Status500InternalServerError);
}
var signature = context.Request.Headers["X-Signature"].ToString();
var timestampHeader = context.Request.Headers["X-Timestamp"].ToString();
var nonce = context.Request.Headers["X-Nonce"].ToString();
var clientIdHeader = context.Request.Headers["X-Client-Id"].ToString();
if (string.IsNullOrWhiteSpace(signature) || string.IsNullOrWhiteSpace(timestampHeader)
|| string.IsNullOrWhiteSpace(nonce) || string.IsNullOrWhiteSpace(clientIdHeader))
{
return Results.Unauthorized();
}
if (!long.TryParse(timestampHeader, out var timestampSeconds))
{
return Results.Unauthorized();
}
var nowSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(nowSeconds - timestampSeconds) > maxSkewSeconds)
{
return Results.StatusCode(StatusCodes.Status401Unauthorized);
}
if (!Guid.TryParse(clientIdHeader, out var clientId))
{
return Results.Unauthorized();
}
var hasClient = await db.AuthClients.AsNoTracking().AnyAsync(x => x.Id == clientId);
if (!hasClient)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
context.Request.EnableBuffering();
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
var body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0;
var expected = ComputeHmacHex(secret, body);
if (!FixedTimeEquals(expected, signature))
{
return Results.Unauthorized();
}
var hasNonce = await db.WebhookNonces.AsNoTracking().AnyAsync(x => x.ClientId == clientId && x.Nonce == nonce);
if (hasNonce)
{
return Results.Conflict(new { error = "replay_detected" });
}
var nonceEntry = new WebhookNonce
{
Id = Guid.NewGuid(),
ClientId = clientId,
Nonce = nonce,
ReceivedAt = DateTimeOffset.UtcNow
};
db.WebhookNonces.Add(nonceEntry);
await db.SaveChangesAsync();
return null;
}
private static string ComputeHmacHex(string secret, string payload)
{
var key = Encoding.UTF8.GetBytes(secret);
using var hmac = new HMACSHA256(key);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool FixedTimeEquals(string a, string b)
{
var aBytes = Encoding.UTF8.GetBytes(a);
var bBytes = Encoding.UTF8.GetBytes(b);
return aBytes.Length == bBytes.Length && CryptographicOperations.FixedTimeEquals(aBytes, bBytes);
}
}

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.23" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SendEngine.Application\SendEngine.Application.csproj" />
<ProjectReference Include="..\SendEngine.Infrastructure\SendEngine.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@SendEngine.Api_HostAddress = http://localhost:5024
GET {{SendEngine.Api_HostAddress}}/health
Accept: application/json
###

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

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

View File

@ -0,0 +1,12 @@
namespace SendEngine.Domain.Entities;
public sealed class AuthClient
{
public Guid Id { get; set; }
public Guid? TenantId { get; set; }
public string ClientId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string[] Scopes { get; set; } = Array.Empty<string>();
public string Status { get; set; } = "active";
public DateTimeOffset CreatedAt { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace SendEngine.Domain.Entities;
public sealed class AuthClientKey
{
public Guid Id { get; set; }
public Guid ClientId { get; set; }
public string KeyHash { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace SendEngine.Domain.Entities;
public sealed class Campaign
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid ListId { get; set; }
public string? Name { get; set; }
public string? Subject { get; set; }
public string? BodyHtml { get; set; }
public string? BodyText { get; set; }
public string? Template { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace SendEngine.Domain.Entities;
public sealed class DeliverySummary
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid SendJobId { get; set; }
public int Total { get; set; }
public int Delivered { get; set; }
public int Bounced { get; set; }
public int Complained { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace SendEngine.Domain.Entities;
public sealed class EventInbox
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string EventType { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTimeOffset ReceivedAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
public string Status { get; set; } = "received";
public string? Error { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace SendEngine.Domain.Entities;
public sealed class ListMember
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid ListId { get; set; }
public Guid SubscriberId { get; set; }
public string Status { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace SendEngine.Domain.Entities;
public sealed class MailingList
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Name { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace SendEngine.Domain.Entities;
public sealed class SendBatch
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid SendJobId { get; set; }
public string Status { get; set; } = "queued";
public int Size { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace SendEngine.Domain.Entities;
public sealed class SendJob
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid ListId { get; set; }
public Guid CampaignId { get; set; }
public DateTimeOffset? ScheduledAt { get; set; }
public DateTimeOffset? WindowStart { get; set; }
public DateTimeOffset? WindowEnd { get; set; }
public string Status { get; set; } = "pending";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace SendEngine.Domain.Entities;
public sealed class Subscriber
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Email { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? Preferences { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace SendEngine.Domain.Entities;
public sealed class Tenant
{
public Guid Id { get; set; }
public string? Name { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace SendEngine.Domain.Entities;
public sealed class WebhookNonce
{
public Guid Id { get; set; }
public Guid ClientId { get; set; }
public string Nonce { get; set; } = string.Empty;
public DateTimeOffset ReceivedAt { 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,686 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using SendEngine.Infrastructure.Data;
#nullable disable
namespace SendEngine.Infrastructure.Data.Migrations
{
[DbContext(typeof(SendEngineDbContext))]
[Migration("20260210083240_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("SendEngine.Domain.Entities.AuthClient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("TenantId")
.HasDatabaseName("idx_auth_clients_tenant");
b.ToTable("auth_clients", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.AuthClientKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("KeyHash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("key_hash");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.HasKey("Id");
b.HasIndex("ClientId")
.HasDatabaseName("idx_auth_client_keys_client");
b.ToTable("auth_client_keys", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("BodyHtml")
.HasColumnType("text")
.HasColumnName("body_html");
b.Property<string>("BodyText")
.HasColumnType("text")
.HasColumnName("body_text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("ListId")
.HasColumnType("uuid")
.HasColumnName("list_id");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Subject")
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("Template")
.HasColumnType("jsonb")
.HasColumnName("template");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("ListId");
b.HasIndex("TenantId")
.HasDatabaseName("idx_campaigns_tenant");
b.ToTable("campaigns", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.DeliverySummary", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<int>("Bounced")
.HasColumnType("integer")
.HasColumnName("bounced");
b.Property<int>("Complained")
.HasColumnType("integer")
.HasColumnName("complained");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("Delivered")
.HasColumnType("integer")
.HasColumnName("delivered");
b.Property<Guid>("SendJobId")
.HasColumnType("uuid")
.HasColumnName("send_job_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<int>("Total")
.HasColumnType("integer")
.HasColumnName("total");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("SendJobId")
.HasDatabaseName("idx_delivery_summary_job");
b.HasIndex("TenantId", "SendJobId")
.IsUnique();
b.ToTable("delivery_summary", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.EventInbox", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("event_type");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("EventType")
.HasDatabaseName("idx_events_inbox_type");
b.HasIndex("Status")
.HasDatabaseName("idx_events_inbox_status");
b.HasIndex("TenantId")
.HasDatabaseName("idx_events_inbox_tenant");
b.ToTable("events_inbox", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("ListId")
.HasColumnType("uuid")
.HasColumnName("list_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid")
.HasColumnName("subscriber_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_list_members_list");
b.HasIndex("SubscriberId");
b.HasIndex("TenantId")
.HasDatabaseName("idx_list_members_tenant");
b.HasIndex("TenantId", "ListId", "SubscriberId")
.IsUnique();
b.ToTable("list_members", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("TenantId")
.HasDatabaseName("idx_lists_tenant");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("SendJobId")
.HasColumnType("uuid")
.HasColumnName("send_job_id");
b.Property<int>("Size")
.HasColumnType("integer")
.HasColumnName("size");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("SendJobId")
.HasDatabaseName("idx_send_batches_job");
b.HasIndex("Status")
.HasDatabaseName("idx_send_batches_status");
b.HasIndex("TenantId");
b.ToTable("send_batches", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid")
.HasColumnName("campaign_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("ListId")
.HasColumnType("uuid")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ScheduledAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("scheduled_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<DateTimeOffset?>("WindowEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("window_end");
b.Property<DateTimeOffset?>("WindowStart")
.HasColumnType("timestamp with time zone")
.HasColumnName("window_start");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("ListId");
b.HasIndex("Status")
.HasDatabaseName("idx_send_jobs_status");
b.HasIndex("TenantId")
.HasDatabaseName("idx_send_jobs_tenant");
b.ToTable("send_jobs", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext")
.HasColumnName("email");
b.Property<string>("Preferences")
.HasColumnType("jsonb")
.HasColumnName("preferences");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("TenantId")
.HasDatabaseName("idx_subscribers_tenant");
b.HasIndex("TenantId", "Email")
.IsUnique();
b.ToTable("subscribers", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id");
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<string>("Nonce")
.IsRequired()
.HasColumnType("text")
.HasColumnName("nonce");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.HasKey("Id");
b.HasIndex("ClientId")
.HasDatabaseName("idx_webhook_nonces_client");
b.HasIndex("ClientId", "Nonce")
.IsUnique();
b.ToTable("webhook_nonces", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.AuthClientKey", b =>
{
b.HasOne("SendEngine.Domain.Entities.AuthClient", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_client_keys_client");
});
modelBuilder.Entity("SendEngine.Domain.Entities.Campaign", b =>
{
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
.WithMany()
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_campaigns_list");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_campaigns_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.DeliverySummary", b =>
{
b.HasOne("SendEngine.Domain.Entities.SendJob", null)
.WithMany()
.HasForeignKey("SendJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_delivery_summary_job");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_delivery_summary_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.EventInbox", b =>
{
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_events_inbox_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
{
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
.WithMany()
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_list_members_list");
b.HasOne("SendEngine.Domain.Entities.Subscriber", null)
.WithMany()
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_list_members_subscriber");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_list_members_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
{
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_lists_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendBatch", b =>
{
b.HasOne("SendEngine.Domain.Entities.SendJob", null)
.WithMany()
.HasForeignKey("SendJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_batches_job");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_batches_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendJob", b =>
{
b.HasOne("SendEngine.Domain.Entities.Campaign", null)
.WithMany()
.HasForeignKey("CampaignId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_jobs_campaign");
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
.WithMany()
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_jobs_list");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_jobs_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
{
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_subscribers_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b =>
{
b.HasOne("SendEngine.Domain.Entities.AuthClient", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_webhook_nonces_client");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,490 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SendEngine.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:citext", ",,");
migrationBuilder.CreateTable(
name: "auth_clients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
client_id = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
scopes = table.Column<string[]>(type: "text[]", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_auth_clients", 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: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tenants", x => x.id);
});
migrationBuilder.CreateTable(
name: "auth_client_keys",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
client_id = table.Column<Guid>(type: "uuid", nullable: false),
key_hash = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
revoked_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_auth_client_keys", x => x.id);
table.ForeignKey(
name: "fk_auth_client_keys_client",
column: x => x.client_id,
principalTable: "auth_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "webhook_nonces",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
client_id = table.Column<Guid>(type: "uuid", nullable: false),
nonce = table.Column<string>(type: "text", nullable: false),
received_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_webhook_nonces", x => x.id);
table.ForeignKey(
name: "fk_webhook_nonces_client",
column: x => x.client_id,
principalTable: "auth_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "events_inbox",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
event_type = table.Column<string>(type: "text", nullable: false),
source = table.Column<string>(type: "text", nullable: false),
payload = table.Column<string>(type: "jsonb", nullable: false),
received_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
status = table.Column<string>(type: "text", nullable: false),
error = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_events_inbox", x => x.id);
table.ForeignKey(
name: "fk_events_inbox_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "lists",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_lists", x => x.id);
table.ForeignKey(
name: "fk_lists_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subscribers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
email = table.Column<string>(type: "citext", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
preferences = table.Column<string>(type: "jsonb", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subscribers", x => x.id);
table.ForeignKey(
name: "fk_subscribers_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "campaigns",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
list_id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "text", nullable: true),
subject = table.Column<string>(type: "text", nullable: true),
body_html = table.Column<string>(type: "text", nullable: true),
body_text = table.Column<string>(type: "text", nullable: true),
template = table.Column<string>(type: "jsonb", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_campaigns", x => x.id);
table.ForeignKey(
name: "fk_campaigns_list",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_campaigns_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "list_members",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
list_id = table.Column<Guid>(type: "uuid", nullable: false),
subscriber_id = table.Column<Guid>(type: "uuid", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_list_members", x => x.id);
table.ForeignKey(
name: "fk_list_members_list",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_list_members_subscriber",
column: x => x.subscriber_id,
principalTable: "subscribers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_list_members_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "send_jobs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
list_id = table.Column<Guid>(type: "uuid", nullable: false),
campaign_id = table.Column<Guid>(type: "uuid", nullable: false),
scheduled_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
window_start = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
window_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
status = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_send_jobs", x => x.id);
table.ForeignKey(
name: "fk_send_jobs_campaign",
column: x => x.campaign_id,
principalTable: "campaigns",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_send_jobs_list",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_send_jobs_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "delivery_summary",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
send_job_id = table.Column<Guid>(type: "uuid", nullable: false),
total = table.Column<int>(type: "integer", nullable: false),
delivered = table.Column<int>(type: "integer", nullable: false),
bounced = table.Column<int>(type: "integer", nullable: false),
complained = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_delivery_summary", x => x.id);
table.ForeignKey(
name: "fk_delivery_summary_job",
column: x => x.send_job_id,
principalTable: "send_jobs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_delivery_summary_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "send_batches",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
send_job_id = table.Column<Guid>(type: "uuid", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
size = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_send_batches", x => x.id);
table.ForeignKey(
name: "fk_send_batches_job",
column: x => x.send_job_id,
principalTable: "send_jobs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_send_batches_tenant",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_auth_client_keys_client",
table: "auth_client_keys",
column: "client_id");
migrationBuilder.CreateIndex(
name: "idx_auth_clients_tenant",
table: "auth_clients",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "idx_campaigns_tenant",
table: "campaigns",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_campaigns_list_id",
table: "campaigns",
column: "list_id");
migrationBuilder.CreateIndex(
name: "idx_delivery_summary_job",
table: "delivery_summary",
column: "send_job_id");
migrationBuilder.CreateIndex(
name: "IX_delivery_summary_tenant_id_send_job_id",
table: "delivery_summary",
columns: new[] { "tenant_id", "send_job_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "idx_events_inbox_status",
table: "events_inbox",
column: "status");
migrationBuilder.CreateIndex(
name: "idx_events_inbox_tenant",
table: "events_inbox",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "idx_events_inbox_type",
table: "events_inbox",
column: "event_type");
migrationBuilder.CreateIndex(
name: "idx_list_members_list",
table: "list_members",
column: "list_id");
migrationBuilder.CreateIndex(
name: "idx_list_members_tenant",
table: "list_members",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_list_members_subscriber_id",
table: "list_members",
column: "subscriber_id");
migrationBuilder.CreateIndex(
name: "IX_list_members_tenant_id_list_id_subscriber_id",
table: "list_members",
columns: new[] { "tenant_id", "list_id", "subscriber_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "idx_lists_tenant",
table: "lists",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "idx_send_batches_job",
table: "send_batches",
column: "send_job_id");
migrationBuilder.CreateIndex(
name: "idx_send_batches_status",
table: "send_batches",
column: "status");
migrationBuilder.CreateIndex(
name: "IX_send_batches_tenant_id",
table: "send_batches",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "idx_send_jobs_status",
table: "send_jobs",
column: "status");
migrationBuilder.CreateIndex(
name: "idx_send_jobs_tenant",
table: "send_jobs",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_send_jobs_campaign_id",
table: "send_jobs",
column: "campaign_id");
migrationBuilder.CreateIndex(
name: "IX_send_jobs_list_id",
table: "send_jobs",
column: "list_id");
migrationBuilder.CreateIndex(
name: "idx_subscribers_tenant",
table: "subscribers",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_subscribers_tenant_id_email",
table: "subscribers",
columns: new[] { "tenant_id", "email" },
unique: true);
migrationBuilder.CreateIndex(
name: "idx_webhook_nonces_client",
table: "webhook_nonces",
column: "client_id");
migrationBuilder.CreateIndex(
name: "IX_webhook_nonces_client_id_nonce",
table: "webhook_nonces",
columns: new[] { "client_id", "nonce" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "auth_client_keys");
migrationBuilder.DropTable(
name: "delivery_summary");
migrationBuilder.DropTable(
name: "events_inbox");
migrationBuilder.DropTable(
name: "list_members");
migrationBuilder.DropTable(
name: "send_batches");
migrationBuilder.DropTable(
name: "webhook_nonces");
migrationBuilder.DropTable(
name: "subscribers");
migrationBuilder.DropTable(
name: "send_jobs");
migrationBuilder.DropTable(
name: "auth_clients");
migrationBuilder.DropTable(
name: "campaigns");
migrationBuilder.DropTable(
name: "lists");
migrationBuilder.DropTable(
name: "tenants");
}
}
}

View File

@ -0,0 +1,683 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using SendEngine.Infrastructure.Data;
#nullable disable
namespace SendEngine.Infrastructure.Data.Migrations
{
[DbContext(typeof(SendEngineDbContext))]
partial class SendEngineDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("SendEngine.Domain.Entities.AuthClient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("TenantId")
.HasDatabaseName("idx_auth_clients_tenant");
b.ToTable("auth_clients", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.AuthClientKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("KeyHash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("key_hash");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.HasKey("Id");
b.HasIndex("ClientId")
.HasDatabaseName("idx_auth_client_keys_client");
b.ToTable("auth_client_keys", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("BodyHtml")
.HasColumnType("text")
.HasColumnName("body_html");
b.Property<string>("BodyText")
.HasColumnType("text")
.HasColumnName("body_text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("ListId")
.HasColumnType("uuid")
.HasColumnName("list_id");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Subject")
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("Template")
.HasColumnType("jsonb")
.HasColumnName("template");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("ListId");
b.HasIndex("TenantId")
.HasDatabaseName("idx_campaigns_tenant");
b.ToTable("campaigns", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.DeliverySummary", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<int>("Bounced")
.HasColumnType("integer")
.HasColumnName("bounced");
b.Property<int>("Complained")
.HasColumnType("integer")
.HasColumnName("complained");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("Delivered")
.HasColumnType("integer")
.HasColumnName("delivered");
b.Property<Guid>("SendJobId")
.HasColumnType("uuid")
.HasColumnName("send_job_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<int>("Total")
.HasColumnType("integer")
.HasColumnName("total");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("SendJobId")
.HasDatabaseName("idx_delivery_summary_job");
b.HasIndex("TenantId", "SendJobId")
.IsUnique();
b.ToTable("delivery_summary", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.EventInbox", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("event_type");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("EventType")
.HasDatabaseName("idx_events_inbox_type");
b.HasIndex("Status")
.HasDatabaseName("idx_events_inbox_status");
b.HasIndex("TenantId")
.HasDatabaseName("idx_events_inbox_tenant");
b.ToTable("events_inbox", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("ListId")
.HasColumnType("uuid")
.HasColumnName("list_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid")
.HasColumnName("subscriber_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_list_members_list");
b.HasIndex("SubscriberId");
b.HasIndex("TenantId")
.HasDatabaseName("idx_list_members_tenant");
b.HasIndex("TenantId", "ListId", "SubscriberId")
.IsUnique();
b.ToTable("list_members", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id");
b.HasIndex("TenantId")
.HasDatabaseName("idx_lists_tenant");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("SendJobId")
.HasColumnType("uuid")
.HasColumnName("send_job_id");
b.Property<int>("Size")
.HasColumnType("integer")
.HasColumnName("size");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("SendJobId")
.HasDatabaseName("idx_send_batches_job");
b.HasIndex("Status")
.HasDatabaseName("idx_send_batches_status");
b.HasIndex("TenantId");
b.ToTable("send_batches", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid")
.HasColumnName("campaign_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("ListId")
.HasColumnType("uuid")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ScheduledAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("scheduled_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<DateTimeOffset?>("WindowEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("window_end");
b.Property<DateTimeOffset?>("WindowStart")
.HasColumnType("timestamp with time zone")
.HasColumnName("window_start");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("ListId");
b.HasIndex("Status")
.HasDatabaseName("idx_send_jobs_status");
b.HasIndex("TenantId")
.HasDatabaseName("idx_send_jobs_tenant");
b.ToTable("send_jobs", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext")
.HasColumnName("email");
b.Property<string>("Preferences")
.HasColumnType("jsonb")
.HasColumnName("preferences");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("TenantId")
.HasDatabaseName("idx_subscribers_tenant");
b.HasIndex("TenantId", "Email")
.IsUnique();
b.ToTable("subscribers", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id");
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<string>("Nonce")
.IsRequired()
.HasColumnType("text")
.HasColumnName("nonce");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.HasKey("Id");
b.HasIndex("ClientId")
.HasDatabaseName("idx_webhook_nonces_client");
b.HasIndex("ClientId", "Nonce")
.IsUnique();
b.ToTable("webhook_nonces", (string)null);
});
modelBuilder.Entity("SendEngine.Domain.Entities.AuthClientKey", b =>
{
b.HasOne("SendEngine.Domain.Entities.AuthClient", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_client_keys_client");
});
modelBuilder.Entity("SendEngine.Domain.Entities.Campaign", b =>
{
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
.WithMany()
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_campaigns_list");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_campaigns_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.DeliverySummary", b =>
{
b.HasOne("SendEngine.Domain.Entities.SendJob", null)
.WithMany()
.HasForeignKey("SendJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_delivery_summary_job");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_delivery_summary_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.EventInbox", b =>
{
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_events_inbox_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
{
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
.WithMany()
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_list_members_list");
b.HasOne("SendEngine.Domain.Entities.Subscriber", null)
.WithMany()
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_list_members_subscriber");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_list_members_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
{
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_lists_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendBatch", b =>
{
b.HasOne("SendEngine.Domain.Entities.SendJob", null)
.WithMany()
.HasForeignKey("SendJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_batches_job");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_batches_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.SendJob", b =>
{
b.HasOne("SendEngine.Domain.Entities.Campaign", null)
.WithMany()
.HasForeignKey("CampaignId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_jobs_campaign");
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
.WithMany()
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_jobs_list");
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_send_jobs_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
{
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_subscribers_tenant");
});
modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b =>
{
b.HasOne("SendEngine.Domain.Entities.AuthClient", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_webhook_nonces_client");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,219 @@
using Microsoft.EntityFrameworkCore;
using SendEngine.Domain.Entities;
namespace SendEngine.Infrastructure.Data;
public sealed class SendEngineDbContext : DbContext
{
public SendEngineDbContext(DbContextOptions<SendEngineDbContext> options) : base(options)
{
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<MailingList> Lists => Set<MailingList>();
public DbSet<Subscriber> Subscribers => Set<Subscriber>();
public DbSet<ListMember> ListMembers => Set<ListMember>();
public DbSet<EventInbox> EventsInbox => Set<EventInbox>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<SendJob> SendJobs => Set<SendJob>();
public DbSet<SendBatch> SendBatches => Set<SendBatch>();
public DbSet<DeliverySummary> DeliverySummaries => Set<DeliverySummary>();
public DbSet<AuthClient> AuthClients => Set<AuthClient>();
public DbSet<AuthClientKey> AuthClientKeys => Set<AuthClientKey>();
public DbSet<WebhookNonce> WebhookNonces => Set<WebhookNonce>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("citext");
modelBuilder.Entity<Tenant>(entity =>
{
entity.ToTable("tenants");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
});
modelBuilder.Entity<MailingList>(entity =>
{
entity.ToTable("lists");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_lists_tenant");
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_lists_tenant");
});
modelBuilder.Entity<Subscriber>(entity =>
{
entity.ToTable("subscribers");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Email).HasColumnName("email").HasColumnType("citext");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Preferences).HasColumnName("preferences").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_subscribers_tenant");
entity.HasIndex(e => new { e.TenantId, e.Email }).IsUnique();
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_subscribers_tenant");
});
modelBuilder.Entity<ListMember>(entity =>
{
entity.ToTable("list_members");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ListId).HasColumnName("list_id");
entity.Property(e => e.SubscriberId).HasColumnName("subscriber_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_list_members_tenant");
entity.HasIndex(e => e.ListId).HasDatabaseName("idx_list_members_list");
entity.HasIndex(e => new { e.TenantId, e.ListId, e.SubscriberId }).IsUnique();
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_list_members_tenant");
entity.HasOne<MailingList>().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_list_members_list");
entity.HasOne<Subscriber>().WithMany().HasForeignKey(e => e.SubscriberId).HasConstraintName("fk_list_members_subscriber");
});
modelBuilder.Entity<EventInbox>(entity =>
{
entity.ToTable("events_inbox");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.EventType).HasColumnName("event_type");
entity.Property(e => e.Source).HasColumnName("source");
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
entity.Property(e => e.ReceivedAt).HasColumnName("received_at");
entity.Property(e => e.ProcessedAt).HasColumnName("processed_at");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Error).HasColumnName("error");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_events_inbox_tenant");
entity.HasIndex(e => e.EventType).HasDatabaseName("idx_events_inbox_type");
entity.HasIndex(e => e.Status).HasDatabaseName("idx_events_inbox_status");
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_events_inbox_tenant");
});
modelBuilder.Entity<Campaign>(entity =>
{
entity.ToTable("campaigns");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ListId).HasColumnName("list_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Subject).HasColumnName("subject");
entity.Property(e => e.BodyHtml).HasColumnName("body_html");
entity.Property(e => e.BodyText).HasColumnName("body_text");
entity.Property(e => e.Template).HasColumnName("template").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_campaigns_tenant");
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_campaigns_tenant");
entity.HasOne<MailingList>().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_campaigns_list");
});
modelBuilder.Entity<SendJob>(entity =>
{
entity.ToTable("send_jobs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ListId).HasColumnName("list_id");
entity.Property(e => e.CampaignId).HasColumnName("campaign_id");
entity.Property(e => e.ScheduledAt).HasColumnName("scheduled_at");
entity.Property(e => e.WindowStart).HasColumnName("window_start");
entity.Property(e => e.WindowEnd).HasColumnName("window_end");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_send_jobs_tenant");
entity.HasIndex(e => e.Status).HasDatabaseName("idx_send_jobs_status");
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_send_jobs_tenant");
entity.HasOne<MailingList>().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_send_jobs_list");
entity.HasOne<Campaign>().WithMany().HasForeignKey(e => e.CampaignId).HasConstraintName("fk_send_jobs_campaign");
});
modelBuilder.Entity<SendBatch>(entity =>
{
entity.ToTable("send_batches");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.SendJobId).HasColumnName("send_job_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Size).HasColumnName("size");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.HasIndex(e => e.SendJobId).HasDatabaseName("idx_send_batches_job");
entity.HasIndex(e => e.Status).HasDatabaseName("idx_send_batches_status");
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_send_batches_tenant");
entity.HasOne<SendJob>().WithMany().HasForeignKey(e => e.SendJobId).HasConstraintName("fk_send_batches_job");
});
modelBuilder.Entity<DeliverySummary>(entity =>
{
entity.ToTable("delivery_summary");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.SendJobId).HasColumnName("send_job_id");
entity.Property(e => e.Total).HasColumnName("total");
entity.Property(e => e.Delivered).HasColumnName("delivered");
entity.Property(e => e.Bounced).HasColumnName("bounced");
entity.Property(e => e.Complained).HasColumnName("complained");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.HasIndex(e => e.SendJobId).HasDatabaseName("idx_delivery_summary_job");
entity.HasIndex(e => new { e.TenantId, e.SendJobId }).IsUnique();
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_delivery_summary_tenant");
entity.HasOne<SendJob>().WithMany().HasForeignKey(e => e.SendJobId).HasConstraintName("fk_delivery_summary_job");
});
modelBuilder.Entity<AuthClient>(entity =>
{
entity.ToTable("auth_clients");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ClientId).HasColumnName("client_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Scopes).HasColumnName("scopes");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_auth_clients_tenant");
});
modelBuilder.Entity<AuthClientKey>(entity =>
{
entity.ToTable("auth_client_keys");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.ClientId).HasColumnName("client_id");
entity.Property(e => e.KeyHash).HasColumnName("key_hash");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
entity.HasIndex(e => e.ClientId).HasDatabaseName("idx_auth_client_keys_client");
entity.HasOne<AuthClient>().WithMany().HasForeignKey(e => e.ClientId).HasConstraintName("fk_auth_client_keys_client");
});
modelBuilder.Entity<WebhookNonce>(entity =>
{
entity.ToTable("webhook_nonces");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.ClientId).HasColumnName("client_id");
entity.Property(e => e.Nonce).HasColumnName("nonce");
entity.Property(e => e.ReceivedAt).HasColumnName("received_at");
entity.HasIndex(e => e.ClientId).HasDatabaseName("idx_webhook_nonces_client");
entity.HasIndex(e => new { e.ClientId, e.Nonce }).IsUnique();
entity.HasOne<AuthClient>().WithMany().HasForeignKey(e => e.ClientId).HasConstraintName("fk_webhook_nonces_client");
});
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace SendEngine.Infrastructure.Data;
public sealed class SendEngineDbContextFactory : IDesignTimeDbContextFactory<SendEngineDbContext>
{
public SendEngineDbContext CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
var connectionString = configuration.GetConnectionString("Default")
?? configuration["ConnectionStrings__Default"]
?? "Host=localhost;Database=send_engine;Username=postgres;Password=postgres";
var optionsBuilder = new DbContextOptionsBuilder<SendEngineDbContext>();
optionsBuilder.UseNpgsql(connectionString);
return new SendEngineDbContext(optionsBuilder.Options);
}
}

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SendEngine.Infrastructure.Data;
namespace SendEngine.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("Default")
?? configuration["ConnectionStrings__Default"]
?? "Host=localhost;Database=send_engine;Username=postgres;Password=postgres";
services.AddDbContext<SendEngineDbContext>(options =>
options.UseNpgsql(connectionString));
return services;
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SendEngine.Domain\SendEngine.Domain.csproj" />
<ProjectReference Include="..\SendEngine.Application\SendEngine.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SendEngine.Infrastructure;
using SendEngine.Infrastructure.Data;
var command = args.Length > 0 ? args[0] : "migrate";
if (command is "-h" or "--help" or "help")
{
Console.WriteLine("SendEngine Installer");
Console.WriteLine("Usage:");
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
return;
}
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
var services = new ServiceCollection();
services.AddInfrastructure(configuration);
var provider = services.BuildServiceProvider();
if (command == "migrate")
{
using var scope = provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
db.Database.Migrate();
Console.WriteLine("Database migration completed.");
return;
}
Console.WriteLine($"Unknown command: {command}");

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SendEngine.Infrastructure\SendEngine.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>