warrenchen e9712fb1f7 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.
2026-02-10 17:56:29 +09:00

287 lines
8.7 KiB
C#

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