From e9712fb1f7a8debf1e012b194b7f0bf636efc970 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Tue, 10 Feb 2026 17:56:29 +0900 Subject: [PATCH] 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. --- .env.example | 7 + README.md | 3 + SendEngine.sln | 59 +- docs/INSTALL.md | 4 + docs/OPENAPI.md | 4 +- docs/openapi.yaml | 6 + src/SendEngine.Api/Models/SendJobModels.cs | 48 ++ src/SendEngine.Api/Models/WebhookModels.cs | 40 + src/SendEngine.Api/Program.cs | 286 ++++++++ .../Properties/launchSettings.json | 41 ++ .../Security/WebhookValidator.cs | 95 +++ src/SendEngine.Api/SendEngine.Api.csproj | 20 + src/SendEngine.Api/SendEngine.Api.http | 6 + src/SendEngine.Api/appsettings.json | 9 + .../SendEngine.Application.csproj | 13 + src/SendEngine.Domain/Entities/AuthClient.cs | 12 + .../Entities/AuthClientKey.cs | 10 + src/SendEngine.Domain/Entities/Campaign.cs | 14 + .../Entities/DeliverySummary.cs | 14 + src/SendEngine.Domain/Entities/EventInbox.cs | 14 + src/SendEngine.Domain/Entities/ListMember.cs | 12 + src/SendEngine.Domain/Entities/MailingList.cs | 9 + src/SendEngine.Domain/Entities/SendBatch.cs | 12 + src/SendEngine.Domain/Entities/SendJob.cs | 15 + src/SendEngine.Domain/Entities/Subscriber.cs | 12 + src/SendEngine.Domain/Entities/Tenant.cs | 8 + .../Entities/WebhookNonce.cs | 9 + .../SendEngine.Domain.csproj | 9 + .../20260210083240_Initial.Designer.cs | 686 ++++++++++++++++++ .../Data/Migrations/20260210083240_Initial.cs | 490 +++++++++++++ .../SendEngineDbContextModelSnapshot.cs | 683 +++++++++++++++++ .../Data/SendEngineDbContext.cs | 219 ++++++ .../Data/SendEngineDbContextFactory.cs | 24 + .../DependencyInjection.cs | 21 + .../SendEngine.Infrastructure.csproj | 25 + src/SendEngine.Installer/Program.cs | 35 + .../SendEngine.Installer.csproj | 20 + 37 files changed, 2989 insertions(+), 5 deletions(-) create mode 100644 src/SendEngine.Api/Models/SendJobModels.cs create mode 100644 src/SendEngine.Api/Models/WebhookModels.cs create mode 100644 src/SendEngine.Api/Program.cs create mode 100644 src/SendEngine.Api/Properties/launchSettings.json create mode 100644 src/SendEngine.Api/Security/WebhookValidator.cs create mode 100644 src/SendEngine.Api/SendEngine.Api.csproj create mode 100644 src/SendEngine.Api/SendEngine.Api.http create mode 100644 src/SendEngine.Api/appsettings.json create mode 100644 src/SendEngine.Application/SendEngine.Application.csproj create mode 100644 src/SendEngine.Domain/Entities/AuthClient.cs create mode 100644 src/SendEngine.Domain/Entities/AuthClientKey.cs create mode 100644 src/SendEngine.Domain/Entities/Campaign.cs create mode 100644 src/SendEngine.Domain/Entities/DeliverySummary.cs create mode 100644 src/SendEngine.Domain/Entities/EventInbox.cs create mode 100644 src/SendEngine.Domain/Entities/ListMember.cs create mode 100644 src/SendEngine.Domain/Entities/MailingList.cs create mode 100644 src/SendEngine.Domain/Entities/SendBatch.cs create mode 100644 src/SendEngine.Domain/Entities/SendJob.cs create mode 100644 src/SendEngine.Domain/Entities/Subscriber.cs create mode 100644 src/SendEngine.Domain/Entities/Tenant.cs create mode 100644 src/SendEngine.Domain/Entities/WebhookNonce.cs create mode 100644 src/SendEngine.Domain/SendEngine.Domain.csproj create mode 100644 src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.Designer.cs create mode 100644 src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.cs create mode 100644 src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs create mode 100644 src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs create mode 100644 src/SendEngine.Infrastructure/Data/SendEngineDbContextFactory.cs create mode 100644 src/SendEngine.Infrastructure/DependencyInjection.cs create mode 100644 src/SendEngine.Infrastructure/SendEngine.Infrastructure.csproj create mode 100644 src/SendEngine.Installer/Program.cs create mode 100644 src/SendEngine.Installer/SendEngine.Installer.csproj diff --git a/.env.example b/.env.example index 1630969..4678b56 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 0ba5066..37d7d1d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/SendEngine.sln b/SendEngine.sln index 03a3aa0..39690e4 100644 --- a/SendEngine.sln +++ b/SendEngine.sln @@ -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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 19d1498..9eed1e5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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` diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 009ee2e..c6a4103 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -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 Credentials(Send Engine 作為 client) @@ -205,7 +207,7 @@ Endpoint: - `POST /webhooks/ses` 驗證: -- 依 SES/SNS 規格驗簽 +- 依 SES/SNS 規格驗簽(可用 `Ses__SkipSignatureValidation=true` 暫時略過) Request Body(示意): ```json diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e6b6c4d..0033ded 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/src/SendEngine.Api/Models/SendJobModels.cs b/src/SendEngine.Api/Models/SendJobModels.cs new file mode 100644 index 0000000..9f0c5b1 --- /dev/null +++ b/src/SendEngine.Api/Models/SendJobModels.cs @@ -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; +} diff --git a/src/SendEngine.Api/Models/WebhookModels.cs b/src/SendEngine.Api/Models/WebhookModels.cs new file mode 100644 index 0000000..d9e7b7a --- /dev/null +++ b/src/SendEngine.Api/Models/WebhookModels.cs @@ -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? 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 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; } +} diff --git a/src/SendEngine.Api/Program.cs b/src/SendEngine.Api/Program.cs new file mode 100644 index 0000000..f49d8ed --- /dev/null +++ b/src/SendEngine.Api/Program.cs @@ -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(); + 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; +} diff --git a/src/SendEngine.Api/Properties/launchSettings.json b/src/SendEngine.Api/Properties/launchSettings.json new file mode 100644 index 0000000..bff1f71 --- /dev/null +++ b/src/SendEngine.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/SendEngine.Api/Security/WebhookValidator.cs b/src/SendEngine.Api/Security/WebhookValidator.cs new file mode 100644 index 0000000..0f2ec1d --- /dev/null +++ b/src/SendEngine.Api/Security/WebhookValidator.cs @@ -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 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); + } +} diff --git a/src/SendEngine.Api/SendEngine.Api.csproj b/src/SendEngine.Api/SendEngine.Api.csproj new file mode 100644 index 0000000..951623d --- /dev/null +++ b/src/SendEngine.Api/SendEngine.Api.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/SendEngine.Api/SendEngine.Api.http b/src/SendEngine.Api/SendEngine.Api.http new file mode 100644 index 0000000..9ba0e9e --- /dev/null +++ b/src/SendEngine.Api/SendEngine.Api.http @@ -0,0 +1,6 @@ +@SendEngine.Api_HostAddress = http://localhost:5024 + +GET {{SendEngine.Api_HostAddress}}/health +Accept: application/json + +### diff --git a/src/SendEngine.Api/appsettings.json b/src/SendEngine.Api/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/src/SendEngine.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/SendEngine.Application/SendEngine.Application.csproj b/src/SendEngine.Application/SendEngine.Application.csproj new file mode 100644 index 0000000..69d2cf6 --- /dev/null +++ b/src/SendEngine.Application/SendEngine.Application.csproj @@ -0,0 +1,13 @@ + + + + + + + + net8.0 + enable + enable + + + diff --git a/src/SendEngine.Domain/Entities/AuthClient.cs b/src/SendEngine.Domain/Entities/AuthClient.cs new file mode 100644 index 0000000..fccefb7 --- /dev/null +++ b/src/SendEngine.Domain/Entities/AuthClient.cs @@ -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(); + public string Status { get; set; } = "active"; + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/SendEngine.Domain/Entities/AuthClientKey.cs b/src/SendEngine.Domain/Entities/AuthClientKey.cs new file mode 100644 index 0000000..2f3da14 --- /dev/null +++ b/src/SendEngine.Domain/Entities/AuthClientKey.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/Campaign.cs b/src/SendEngine.Domain/Entities/Campaign.cs new file mode 100644 index 0000000..0a6ddec --- /dev/null +++ b/src/SendEngine.Domain/Entities/Campaign.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/DeliverySummary.cs b/src/SendEngine.Domain/Entities/DeliverySummary.cs new file mode 100644 index 0000000..1d2f7d3 --- /dev/null +++ b/src/SendEngine.Domain/Entities/DeliverySummary.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/EventInbox.cs b/src/SendEngine.Domain/Entities/EventInbox.cs new file mode 100644 index 0000000..7500a49 --- /dev/null +++ b/src/SendEngine.Domain/Entities/EventInbox.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/ListMember.cs b/src/SendEngine.Domain/Entities/ListMember.cs new file mode 100644 index 0000000..33ac39b --- /dev/null +++ b/src/SendEngine.Domain/Entities/ListMember.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/MailingList.cs b/src/SendEngine.Domain/Entities/MailingList.cs new file mode 100644 index 0000000..50c556f --- /dev/null +++ b/src/SendEngine.Domain/Entities/MailingList.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/SendBatch.cs b/src/SendEngine.Domain/Entities/SendBatch.cs new file mode 100644 index 0000000..c405436 --- /dev/null +++ b/src/SendEngine.Domain/Entities/SendBatch.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/SendJob.cs b/src/SendEngine.Domain/Entities/SendJob.cs new file mode 100644 index 0000000..ae9ef4e --- /dev/null +++ b/src/SendEngine.Domain/Entities/SendJob.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/Subscriber.cs b/src/SendEngine.Domain/Entities/Subscriber.cs new file mode 100644 index 0000000..15f0df9 --- /dev/null +++ b/src/SendEngine.Domain/Entities/Subscriber.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/Tenant.cs b/src/SendEngine.Domain/Entities/Tenant.cs new file mode 100644 index 0000000..79b766b --- /dev/null +++ b/src/SendEngine.Domain/Entities/Tenant.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/Entities/WebhookNonce.cs b/src/SendEngine.Domain/Entities/WebhookNonce.cs new file mode 100644 index 0000000..9d772fe --- /dev/null +++ b/src/SendEngine.Domain/Entities/WebhookNonce.cs @@ -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; } +} diff --git a/src/SendEngine.Domain/SendEngine.Domain.csproj b/src/SendEngine.Domain/SendEngine.Domain.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/src/SendEngine.Domain/SendEngine.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.Designer.cs b/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.Designer.cs new file mode 100644 index 0000000..990d3af --- /dev/null +++ b/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.Designer.cs @@ -0,0 +1,686 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_hash"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BodyHtml") + .HasColumnType("text") + .HasColumnName("body_html"); + + b.Property("BodyText") + .HasColumnType("text") + .HasColumnName("body_text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Subject") + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("Template") + .HasColumnType("jsonb") + .HasColumnName("template"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bounced") + .HasColumnType("integer") + .HasColumnName("bounced"); + + b.Property("Complained") + .HasColumnType("integer") + .HasColumnName("complained"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Delivered") + .HasColumnType("integer") + .HasColumnName("delivered"); + + b.Property("SendJobId") + .HasColumnType("uuid") + .HasColumnName("send_job_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_type"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("SubscriberId") + .HasColumnType("uuid") + .HasColumnName("subscriber_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("SendJobId") + .HasColumnType("uuid") + .HasColumnName("send_job_id"); + + b.Property("Size") + .HasColumnType("integer") + .HasColumnName("size"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WindowEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("window_end"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("Preferences") + .HasColumnType("jsonb") + .HasColumnName("preferences"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nonce"); + + b.Property("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 + } + } +} diff --git a/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.cs b/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.cs new file mode 100644 index 0000000..a4893e9 --- /dev/null +++ b/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.cs @@ -0,0 +1,490 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SendEngine.Infrastructure.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:citext", ",,"); + + migrationBuilder.CreateTable( + name: "auth_clients", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: true), + client_id = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + scopes = table.Column(type: "text[]", nullable: false), + status = table.Column(type: "text", nullable: false), + created_at = table.Column(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(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: true), + created_at = table.Column(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(type: "uuid", nullable: false), + client_id = table.Column(type: "uuid", nullable: false), + key_hash = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + revoked_at = table.Column(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(type: "uuid", nullable: false), + client_id = table.Column(type: "uuid", nullable: false), + nonce = table.Column(type: "text", nullable: false), + received_at = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + event_type = table.Column(type: "text", nullable: false), + source = table.Column(type: "text", nullable: false), + payload = table.Column(type: "jsonb", nullable: false), + received_at = table.Column(type: "timestamp with time zone", nullable: false), + processed_at = table.Column(type: "timestamp with time zone", nullable: true), + status = table.Column(type: "text", nullable: false), + error = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: false), + created_at = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "citext", nullable: false), + status = table.Column(type: "text", nullable: false), + preferences = table.Column(type: "jsonb", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + list_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: true), + subject = table.Column(type: "text", nullable: true), + body_html = table.Column(type: "text", nullable: true), + body_text = table.Column(type: "text", nullable: true), + template = table.Column(type: "jsonb", nullable: true), + created_at = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + list_id = table.Column(type: "uuid", nullable: false), + subscriber_id = table.Column(type: "uuid", nullable: false), + status = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + list_id = table.Column(type: "uuid", nullable: false), + campaign_id = table.Column(type: "uuid", nullable: false), + scheduled_at = table.Column(type: "timestamp with time zone", nullable: true), + window_start = table.Column(type: "timestamp with time zone", nullable: true), + window_end = table.Column(type: "timestamp with time zone", nullable: true), + status = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + send_job_id = table.Column(type: "uuid", nullable: false), + total = table.Column(type: "integer", nullable: false), + delivered = table.Column(type: "integer", nullable: false), + bounced = table.Column(type: "integer", nullable: false), + complained = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(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(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + send_job_id = table.Column(type: "uuid", nullable: false), + status = table.Column(type: "text", nullable: false), + size = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs b/src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs new file mode 100644 index 0000000..f2aab36 --- /dev/null +++ b/src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs @@ -0,0 +1,683 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_hash"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BodyHtml") + .HasColumnType("text") + .HasColumnName("body_html"); + + b.Property("BodyText") + .HasColumnType("text") + .HasColumnName("body_text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Subject") + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("Template") + .HasColumnType("jsonb") + .HasColumnName("template"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bounced") + .HasColumnType("integer") + .HasColumnName("bounced"); + + b.Property("Complained") + .HasColumnType("integer") + .HasColumnName("complained"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Delivered") + .HasColumnType("integer") + .HasColumnName("delivered"); + + b.Property("SendJobId") + .HasColumnType("uuid") + .HasColumnName("send_job_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_type"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("SubscriberId") + .HasColumnType("uuid") + .HasColumnName("subscriber_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("SendJobId") + .HasColumnType("uuid") + .HasColumnName("send_job_id"); + + b.Property("Size") + .HasColumnType("integer") + .HasColumnName("size"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WindowEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("window_end"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("Preferences") + .HasColumnType("jsonb") + .HasColumnName("preferences"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nonce"); + + b.Property("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 + } + } +} diff --git a/src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs b/src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs new file mode 100644 index 0000000..7c23ae4 --- /dev/null +++ b/src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs @@ -0,0 +1,219 @@ +using Microsoft.EntityFrameworkCore; +using SendEngine.Domain.Entities; + +namespace SendEngine.Infrastructure.Data; + +public sealed class SendEngineDbContext : DbContext +{ + public SendEngineDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Tenants => Set(); + public DbSet Lists => Set(); + public DbSet Subscribers => Set(); + public DbSet ListMembers => Set(); + public DbSet EventsInbox => Set(); + public DbSet Campaigns => Set(); + public DbSet SendJobs => Set(); + public DbSet SendBatches => Set(); + public DbSet DeliverySummaries => Set(); + public DbSet AuthClients => Set(); + public DbSet AuthClientKeys => Set(); + public DbSet WebhookNonces => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresExtension("citext"); + + modelBuilder.Entity(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(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_lists_tenant"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_subscribers_tenant"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_list_members_tenant"); + entity.HasOne().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_list_members_list"); + entity.HasOne().WithMany().HasForeignKey(e => e.SubscriberId).HasConstraintName("fk_list_members_subscriber"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_events_inbox_tenant"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_campaigns_tenant"); + entity.HasOne().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_campaigns_list"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_send_jobs_tenant"); + entity.HasOne().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_send_jobs_list"); + entity.HasOne().WithMany().HasForeignKey(e => e.CampaignId).HasConstraintName("fk_send_jobs_campaign"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_send_batches_tenant"); + entity.HasOne().WithMany().HasForeignKey(e => e.SendJobId).HasConstraintName("fk_send_batches_job"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_delivery_summary_tenant"); + entity.HasOne().WithMany().HasForeignKey(e => e.SendJobId).HasConstraintName("fk_delivery_summary_job"); + }); + + modelBuilder.Entity(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(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().WithMany().HasForeignKey(e => e.ClientId).HasConstraintName("fk_auth_client_keys_client"); + }); + + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.ClientId).HasConstraintName("fk_webhook_nonces_client"); + }); + } +} diff --git a/src/SendEngine.Infrastructure/Data/SendEngineDbContextFactory.cs b/src/SendEngine.Infrastructure/Data/SendEngineDbContextFactory.cs new file mode 100644 index 0000000..b5a4f4c --- /dev/null +++ b/src/SendEngine.Infrastructure/Data/SendEngineDbContextFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace SendEngine.Infrastructure.Data; + +public sealed class SendEngineDbContextFactory : IDesignTimeDbContextFactory +{ + 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(); + optionsBuilder.UseNpgsql(connectionString); + + return new SendEngineDbContext(optionsBuilder.Options); + } +} diff --git a/src/SendEngine.Infrastructure/DependencyInjection.cs b/src/SendEngine.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..d72d4dc --- /dev/null +++ b/src/SendEngine.Infrastructure/DependencyInjection.cs @@ -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(options => + options.UseNpgsql(connectionString)); + + return services; + } +} diff --git a/src/SendEngine.Infrastructure/SendEngine.Infrastructure.csproj b/src/SendEngine.Infrastructure/SendEngine.Infrastructure.csproj new file mode 100644 index 0000000..a27433e --- /dev/null +++ b/src/SendEngine.Infrastructure/SendEngine.Infrastructure.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + net8.0 + enable + enable + + + diff --git a/src/SendEngine.Installer/Program.cs b/src/SendEngine.Installer/Program.cs new file mode 100644 index 0000000..eda82fd --- /dev/null +++ b/src/SendEngine.Installer/Program.cs @@ -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(); + db.Database.Migrate(); + Console.WriteLine("Database migration completed."); + return; +} + +Console.WriteLine($"Unknown command: {command}"); diff --git a/src/SendEngine.Installer/SendEngine.Installer.csproj b/src/SendEngine.Installer/SendEngine.Installer.csproj new file mode 100644 index 0000000..79065a4 --- /dev/null +++ b/src/SendEngine.Installer/SendEngine.Installer.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + Exe + net8.0 + enable + enable + + +