- 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.
287 lines
8.7 KiB
C#
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;
|
|
}
|