proposal-system/api/src/ProposalSystem.Api/Program.cs
Adam Moussa 0cf3faea47 feat: proposal delivery — email customers the PDF on "Mark as Sent"
Makes the system's namesake feature real: marking a proposal Sent now emails the
customer an expiring link to the branded PDF, and customers are managed (with
contact emails) instead of hardcoded. v1 PR4.

API:
- Customer.ContactEmail + migration; Customer list/update endpoints. Search stays
  additive at GET /api/customers?query= (frozen-mobile + web compat); new paginated
  list at GET /api/customers/list (admin).
- IEmailService (SesEmailService v2 / DevEmailService, dev-gated). MarkSentAsync
  resolves the customer's email, presigns the latest PDF (7d), and sends via SES.
  Email/presign failures are caught + audited and NEVER roll back the Sent transition.
- Startup EF migration guarded by a Postgres advisory lock (concurrency-safe).

Infra:
- SES email identity (proposals@seahavenind.com); least-privilege ses:SendEmail/
  SendRawEmail scoped to the identity ARN + ses:FromAddress condition; SES_FROM_ADDRESS
  env. SES starts in sandbox — production access needed for unverified recipients.

Web:
- Customer management page (/admin/customers): list / create / edit incl. contact email.
- New-proposal form searches real customers (free-solo) instead of a hardcoded value.
- Mark-as-Sent dialog notes the PDF will be emailed to the customer.

GPT-4.1 cross-review (SES IAM): no BLOCK (ses:FromAddress condition applied).
Verified: api build + 121 tests; web tsc + 26 tests; infra tsc; ruff clean.
2026-06-12 18:44:57 -04:00

270 lines
9.5 KiB
C#

using System.Text;
using Amazon.DynamoDBv2;
using Amazon.S3;
using Amazon.SecretsManager;
using Amazon.SimpleEmailV2;
using Amazon.SQS;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using ProposalSystem.Api.Middleware;
using ProposalSystem.Api.Services;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Application.Validators;
using ProposalSystem.Infrastructure.Data;
using ProposalSystem.Infrastructure.Services;
var builder = WebApplication.CreateBuilder(args);
// Dev mode flag (read early for conditional setup)
var devMode = builder.Configuration.GetValue<bool>("Auth:DevMode") && builder.Environment.IsDevelopment();
// AWS SDK clients (skip in dev mode — no real AWS credentials needed)
if (!devMode)
{
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAWSService<IAmazonS3>();
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
builder.Services.AddAWSService<IAmazonDynamoDB>();
builder.Services.AddAWSService<IAmazonSimpleEmailServiceV2>();
}
// Database
var dbSecretArn = builder.Configuration["DB_SECRET_ARN"];
if (!string.IsNullOrEmpty(dbSecretArn))
{
var smClient = new AmazonSecretsManagerClient();
var connectionString = SecretsManagerConnectionString.ResolveAsync(smClient, dbSecretArn).GetAwaiter().GetResult();
builder.Services.AddDbContext<ProposalDbContext>(options =>
options.UseNpgsql(connectionString));
}
else
{
builder.Services.AddDbContext<ProposalDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
}
// Internal API key (for Lambda-to-API calls)
var internalApiKeySecretArn = builder.Configuration["INTERNAL_API_KEY_SECRET_ARN"];
if (!string.IsNullOrEmpty(internalApiKeySecretArn))
{
var smClient = new AmazonSecretsManagerClient();
var secretResponse = smClient.GetSecretValueAsync(new Amazon.SecretsManager.Model.GetSecretValueRequest
{
SecretId = internalApiKeySecretArn,
}).GetAwaiter().GetResult();
builder.Configuration["INTERNAL_API_KEY"] = secretResponse.SecretString;
}
// Authentication
var cognitoAuthority = builder.Configuration["Auth:Authority"];
if (!string.IsNullOrEmpty(cognitoAuthority))
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = cognitoAuthority;
var webClientId = builder.Configuration["COGNITO_WEB_CLIENT_ID"] ?? "";
var mobileClientId = builder.Configuration["COGNITO_MOBILE_CLIENT_ID"] ?? "";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidAudiences = new[] { webClientId, mobileClientId }.Where(s => !string.IsNullOrEmpty(s)).ToList(),
ValidateLifetime = true,
RoleClaimType = "cognito:groups",
};
});
}
else if (devMode)
{
// Fix: API-M14 — dev signing key must come from user-secrets or environment variables,
// never from committed config files. Set via: dotnet user-secrets set "Auth:DevSigningKey" "<value>"
var devSigningKey = builder.Configuration["Auth:DevSigningKey"];
if (string.IsNullOrEmpty(devSigningKey))
throw new InvalidOperationException(
"Auth:DevSigningKey is required when DevMode is enabled. " +
"Set it via user-secrets or environment variables, not in committed config files.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(devSigningKey)),
ValidateIssuer = true,
ValidIssuer = "proposal-system-dev",
ValidateAudience = true,
ValidAudience = "proposal-system-dev",
ValidateLifetime = true,
RoleClaimType = "cognito:groups",
};
});
}
else
{
// Fix: API-M2 — fail loud on missing auth config; app must not silently run unauthenticated
throw new InvalidOperationException(
"Authentication is not configured. Set Auth:Authority for Cognito or Auth:DevMode=true (Development only).");
}
builder.Services.AddAuthorization();
// Services
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IProposalService, ProposalService>();
builder.Services.AddScoped<ILineItemService, LineItemService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
if (devMode)
{
builder.Services.AddScoped<IS3Service, DevS3Service>();
builder.Services.AddScoped<ISiteService, DevSiteService>();
builder.Services.AddScoped<IEmailService, DevEmailService>();
}
else
{
builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISiteService, SiteService>();
builder.Services.AddScoped<IEmailService, SesEmailService>();
}
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))
{
builder.Services.AddScoped<IJobPublisher, NoOpJobPublisher>();
}
else
{
builder.Services.AddScoped<IJobPublisher>(sp =>
{
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
return new SqsJobPublisher(sqsClient, jobsQueueUrl);
});
}
// HTTP client for Cognito token exchange
builder.Services.AddHttpClient();
// Validation
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
// Controllers
builder.Services.AddControllers(options =>
{
options.Filters.Add<ValidationFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// OpenAPI / Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Proposal System API",
Version = "v1",
Description = "Sea Haven Industries proposal management API",
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Cognito JWT access token",
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" },
},
Array.Empty<string>()
},
});
});
// Middleware
builder.Services.AddTransient<GlobalExceptionHandler>();
// Health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<ProposalDbContext>();
// CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var origins = new List<string> { "https://proposals.seahaven.com", "https://d2yevct5e5uuz5.cloudfront.net" };
if (builder.Environment.IsDevelopment())
origins.Add("http://localhost:5173");
policy.WithOrigins(origins.ToArray())
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Lambda hosting
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Proposal System API v1"));
app.UseCors();
app.UseMiddleware<InternalApiKeyMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
if (context.User.Identity?.IsAuthenticated == true)
{
var userService = context.RequestServices.GetRequiredService<ICurrentUserService>();
await userService.ResolveAsync();
}
await next();
});
app.MapControllers();
app.MapHealthChecks("/api/health");
// PR4: Concurrency-safe startup migrations — use a Postgres advisory lock so only one
// Lambda cold-start instance migrates at a time. Follows the same pg_advisory_xact_lock
// pattern used by ProposalNumberGenerator. The lock is released when the transaction commits.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProposalDbContext>();
await using var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
await using var lockCmd = connection.CreateCommand();
lockCmd.CommandText = "SELECT pg_advisory_lock(hashtext('ef_migrations'))";
await lockCmd.ExecuteNonQueryAsync();
try
{
db.Database.Migrate();
}
finally
{
await using var unlockCmd = connection.CreateCommand();
unlockCmd.CommandText = "SELECT pg_advisory_unlock(hashtext('ef_migrations'))";
await unlockCmd.ExecuteNonQueryAsync();
}
}
app.Run();