mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-01 14:03:13 +00:00
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
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.
271 lines
9.5 KiB
C#
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();
|