proposal-system/api/src/ProposalSystem.Api/Program.cs
Adam Moussa 5d84399a0d
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
feat: pricing library — curated priced items feed the RAG corpus (#127)
Adds a managed pricing library so admins can seed/curate reference priced items
directly, instead of the corpus being populated only by ingesting Sent proposals.
v1 PR5.

API:
- PricingLibraryItem entity + migration; /api/pricing-library CRUD (admin), with
  GET {id} reachable by internal Lambda callers (admins role via internal key).
- Create/update publish an ADDITIVE library-ingest SQS job {pricingLibraryItemId},
  wrapped so a publish failure never rolls back the save.

Lambda (library-ingest):
- Additive event-shape branch: pricingLibraryItemId -> fetch item, format markdown,
  upload to pricing-library/{category}/{id}.md, trigger KB sync. The existing
  proposalId path is byte-for-byte unchanged. Explicit error when neither id present;
  warns when both present.

Web:
- Pricing Library management page (/admin/pricing-library): list / create / edit / delete.

GPT-4.1 cross-review on the event-shape change: no BLOCK (neither/both-id handling
applied). Verified: api 159 tests; web tsc + 26 tests; lambdas ruff + 37 pytest.
2026-06-18 12:49:47 -04:00

271 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>();
builder.Services.AddScoped<IPricingLibraryService, PricingLibraryService>();
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();