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; }