Merge origin/main into feature/fix-phase-3

Resolve conflicts:
- AuthController.cs: keep origin/main's granular changed-flag pattern (includes role sync + OIDC validation)
- suggestions/app.py: keep our removal of update_status_to_in_review (FIX-45)

[skip deploy]
This commit is contained in:
Adam Moussa 2026-05-20 19:38:15 -04:00
commit 52da67494e
24 changed files with 577 additions and 99 deletions

View file

@ -6,7 +6,7 @@ Internal proposal management platform for Sea Haven Industries. Dispatchers subm
Monorepo with five primary services: Monorepo with five primary services:
- **.NET 8 API** -- Clean Architecture REST API hosted on Lambda behind API Gateway - **.NET 8 API** -- Clean Architecture REST API hosted on Lambda behind API Gateway (JWT-authorized) with Function URL for internal access
- **React 19 Web** -- MUI v7 admin/dispatcher workspace served via CloudFront + S3 - **React 19 Web** -- MUI v7 admin/dispatcher workspace served via CloudFront + S3
- **React Native Mobile** -- iOS-first field app for dispatchers (offline-capable) - **React Native Mobile** -- iOS-first field app for dispatchers (offline-capable)
- **Python Lambdas** -- PDF extraction, PDF generation, library ingestion, AI suggestions, AOSS index provisioning - **Python Lambdas** -- PDF extraction, PDF generation, library ingestion, AI suggestions, AOSS index provisioning
@ -46,13 +46,13 @@ All resources are in **us-east-1** (account 328440206208).
| CDK Stack | Key Resources | | CDK Stack | Key Resources |
|---|---| |---|---|
| `proposal-system-foundation` | RDS PostgreSQL 15 (t4g.small), S3 buckets, SQS queue + DLQ, Cognito user pool, Secrets Manager | | `proposal-system-foundation` | RDS PostgreSQL 15 (t4g.small), S3 buckets, SQS queue + DLQ, Cognito user pool, Secrets Manager |
| `proposal-system-compute` | API Gateway HTTP API, .NET 8 API Lambda, Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator), OpenSearch Serverless collection, Bedrock KB | | `proposal-system-compute` | API Gateway HTTP API (JWT authorizer), .NET 8 API Lambda + Function URL, Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator), OpenSearch Serverless collection, Bedrock KB |
| `proposal-system-frontend` | CloudFront distribution (S3 OAC) | | `proposal-system-frontend` | CloudFront distribution (S3 OAC) |
| Resource Type | Names | | Resource Type | Names |
|---|---| |---|---|
| S3 Buckets | `proposal-system-uploads`, `proposal-system-generated`, `proposal-system-library`, `seahaven-ios-certificates` | | S3 Buckets | `proposal-system-uploads`, `proposal-system-generated`, `proposal-system-library`, `seahaven-ios-certificates` |
| SQS | `proposal-system-jobs` + `proposal-system-jobs-dlq` (message body filtering by jobType) | | SQS | `proposal-system-jobs` (720s visibility, reportBatchItemFailures) + `proposal-system-jobs-dlq` (message body filtering by jobType) |
| Secrets | `proposal-system/db-credentials`, `proposal-system/internal-api-key` | | Secrets | `proposal-system/db-credentials`, `proposal-system/internal-api-key` |
## Local Development ## Local Development
@ -149,12 +149,29 @@ Build and upload to TestFlight is handled by the `cd-mobile-ios.yaml` reusable w
| `ASC_ISSUER_ID` | App Store Connect issuer | | `ASC_ISSUER_ID` | App Store Connect issuer |
| `ASC_KEY_CONTENT` | App Store Connect API key (base64) | | `ASC_KEY_CONTENT` | App Store Connect API key (base64) |
## Authentication & Authorization
Two-layer auth architecture with defense-in-depth:
| Path | Authorizer | Authentication |
|---|---|---|
| External clients → API Gateway `/{proxy+}` | Cognito JWT authorizer (web + mobile client IDs) | .NET JWT middleware (ValidateAudience=true) |
| `/api/health` | None (public) | None |
| `/api/auth/callback`, `/api/auth/dev-login` | None (unauthenticated) | None (pre-auth endpoints) |
| Internal Lambdas → Function URL | None (NONE auth type) | Internal API key (`X-Internal-Api-Key` header, value from Secrets Manager) |
**Role-based access:** Cognito groups (`dispatchers`, `admins`, `sysadmins`) map to API roles via `cognito:groups` claim. Dispatchers can only see their own proposals (ownership enforced in service layer). VendorProposals and GeneratedPdfs endpoints restricted to admins/sysadmins.
**Internal API key:** Python Lambdas call the .NET API via a Lambda Function URL (bypasses API Gateway JWT check). The `InternalApiKeyMiddleware` validates the key and assigns the `admins` role to the synthetic identity.
## Data Flow ## Data Flow
1. Dispatcher submits proposal request (web or mobile) 1. Dispatcher submits proposal request (web or mobile)
2. API creates proposal record, publishes SQS message 2. API creates proposal record (with advisory-locked number generation), publishes SQS message
3. If vendor PDF attached: `pdf-extract` Lambda parses and structures data 3. If vendor PDF attached: `pdf-extract` Lambda parses and structures data
4. Suggestions Lambda queries Bedrock KB for similar proposals, generates line items via Claude 4. Suggestions Lambda queries Bedrock KB for similar proposals, generates line items via Claude
5. Admin reviews/edits line items in pricing workspace 5. Admin reviews/edits line items in pricing workspace
6. On approval: `pdf-generate` Lambda creates branded PDF 6. On approval: `pdf-generate` Lambda creates branded PDF
7. On send: `library-ingest` Lambda adds approved proposal to KB for future matching 7. On send: `library-ingest` Lambda adds approved proposal to KB for future matching
Failed SQS messages are reported via `batchItemFailures` and retried up to 3 times before moving to the DLQ.

View file

@ -5,6 +5,8 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using ProposalSystem.Domain.Entities; using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data; using ProposalSystem.Infrastructure.Data;
@ -40,7 +42,35 @@ public class AuthController : ControllerBase
return BadRequest(new { message = "Failed to exchange authorization code" }); return BadRequest(new { message = "Failed to exchange authorization code" });
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var idToken = handler.ReadJwtToken(tokenResponse.IdToken);
var authority = _config["Auth:Authority"];
JwtSecurityToken idToken;
if (!string.IsNullOrEmpty(authority))
{
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever());
var oidcConfig = await configManager.GetConfigurationAsync(ct);
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeys = oidcConfig.SigningKeys,
ValidateIssuer = true,
ValidIssuer = authority,
ValidateAudience = true,
ValidAudience = clientId,
ValidateLifetime = true,
};
handler.ValidateToken(tokenResponse.IdToken, validationParams, out var validatedToken);
idToken = (JwtSecurityToken)validatedToken;
}
else
{
idToken = handler.ReadJwtToken(tokenResponse.IdToken);
}
var sub = idToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value var sub = idToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
?? throw new InvalidOperationException("No sub claim in ID token"); ?? throw new InvalidOperationException("No sub claim in ID token");
@ -71,13 +101,17 @@ public class AuthController : ControllerBase
_db.Users.Add(user); _db.Users.Add(user);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
else if (user.Email != email || user.DisplayName != name || user.Role != role) else
{ {
user.Email = email; var changed = false;
user.DisplayName = name; if (user.Email != email) { user.Email = email; changed = true; }
user.Role = role; if (user.DisplayName != name) { user.DisplayName = name; changed = true; }
user.UpdatedAt = DateTime.UtcNow; if (user.Role != role) { user.Role = role; changed = true; }
await _db.SaveChangesAsync(ct); if (changed)
{
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
} }
return Ok(new AuthResponse( return Ok(new AuthResponse(

View file

@ -9,7 +9,7 @@ namespace ProposalSystem.Api.Controllers;
[ApiController] [ApiController]
[Route("api/generated-pdfs")] [Route("api/generated-pdfs")]
[Authorize] [Authorize(Roles = "admins,sysadmins")]
public class GeneratedPdfsController : ControllerBase public class GeneratedPdfsController : ControllerBase
{ {
private readonly ProposalDbContext _db; private readonly ProposalDbContext _db;

View file

@ -8,7 +8,7 @@ namespace ProposalSystem.Api.Controllers;
[ApiController] [ApiController]
[Route("api/vendor-proposals")] [Route("api/vendor-proposals")]
[Authorize] [Authorize(Roles = "admins,sysadmins")]
public class VendorProposalsController : ControllerBase public class VendorProposalsController : ControllerBase
{ {
private readonly ProposalDbContext _db; private readonly ProposalDbContext _db;
@ -38,14 +38,13 @@ public class VendorProposalsController : ControllerBase
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
if (vendor.TotalVendorCost > 0) var proposal = await _db.Proposals.FindAsync(new object[] { vendor.ProposalId }, ct);
if (proposal != null)
{ {
var proposal = await _db.Proposals.FindAsync(new object[] { vendor.ProposalId }, ct); proposal.VendorTotalCost = await _db.VendorProposals
if (proposal != null) .Where(v => v.ProposalId == vendor.ProposalId)
{ .SumAsync(v => v.TotalVendorCost, ct);
proposal.VendorTotalCost = vendor.TotalVendorCost; await _db.SaveChangesAsync(ct);
await _db.SaveChangesAsync(ct);
}
} }
return NoContent(); return NoContent();

View file

@ -63,11 +63,14 @@ if (!string.IsNullOrEmpty(cognitoAuthority))
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.Authority = cognitoAuthority; options.Authority = cognitoAuthority;
var webClientId = builder.Configuration["COGNITO_WEB_CLIENT_ID"] ?? "";
var mobileClientId = builder.Configuration["COGNITO_MOBILE_CLIENT_ID"] ?? "";
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidateIssuer = true, ValidateIssuer = true,
ValidateAudience = false, ValidateAudience = true,
ValidAudiences = new[] { webClientId, mobileClientId }.Where(s => !string.IsNullOrEmpty(s)).ToList(),
ValidateLifetime = true, ValidateLifetime = true,
RoleClaimType = "cognito:groups", RoleClaimType = "cognito:groups",
}; };

View file

@ -94,11 +94,8 @@ public class CurrentUserService : ICurrentUserService
private User GetOrThrow() private User GetOrThrow()
{ {
if (_cachedUser != null) return _cachedUser;
ResolveAsync().GetAwaiter().GetResult();
return _cachedUser return _cachedUser
?? throw new UnauthorizedAccessException("Could not resolve current user"); ?? throw new InvalidOperationException(
"CurrentUserService.ResolveAsync() was not called. Ensure the authentication middleware runs before accessing user properties.");
} }
} }

View file

@ -0,0 +1,38 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
public class BulkUpdateLineItemsValidator : AbstractValidator<BulkUpdateLineItemsRequest>
{
public BulkUpdateLineItemsValidator()
{
RuleFor(x => x.LineItems).NotNull();
RuleForEach(x => x.LineItems).ChildRules(entry =>
{
entry.RuleFor(x => x.Description)
.NotEmpty().WithMessage("Description is required")
.MaximumLength(1000);
entry.RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be positive");
entry.RuleFor(x => x.Unit)
.NotEmpty().WithMessage("Unit is required")
.MaximumLength(50);
entry.RuleFor(x => x.TotalPrice)
.GreaterThanOrEqualTo(0).WithMessage("Total price cannot be negative");
entry.RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0)
.When(x => x.UnitPrice.HasValue)
.WithMessage("Unit price cannot be negative");
entry.RuleFor(x => x.PricingMode).IsInEnum();
entry.RuleFor(x => x.Source).IsInEnum();
entry.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
});
}
}

View file

@ -18,8 +18,13 @@ public class ProposalNumberGenerator : IProposalNumberGenerator
var year = DateTime.UtcNow.Year; var year = DateTime.UtcNow.Year;
var prefix = $"SHI-{year}-"; var prefix = $"SHI-{year}-";
// Advisory lock prevents concurrent number generation within the same transaction
await _db.Database.ExecuteSqlRawAsync(
"SELECT pg_advisory_xact_lock(hashtext('proposal_number_gen'))", ct);
var lastNumber = await _db.Proposals var lastNumber = await _db.Proposals
.Where(p => p.ProposalNumber.StartsWith(prefix)) .Where(p => p.ProposalNumber.StartsWith(prefix))
.Where(p => !p.ProposalNumber.Contains("-R"))
.OrderByDescending(p => p.ProposalNumber) .OrderByDescending(p => p.ProposalNumber)
.Select(p => p.ProposalNumber) .Select(p => p.ProposalNumber)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);

View file

@ -30,6 +30,8 @@ public class ProposalService : IProposalService
public async Task<ProposalResponse> CreateAsync(CreateProposalRequest request, CancellationToken ct = default) public async Task<ProposalResponse> CreateAsync(CreateProposalRequest request, CancellationToken ct = default)
{ {
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var proposalNumber = await _numberGenerator.GenerateAsync(ct); var proposalNumber = await _numberGenerator.GenerateAsync(ct);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -53,6 +55,7 @@ public class ProposalService : IProposalService
_db.Proposals.Add(proposal); _db.Proposals.Add(proposal);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
try try
{ {
@ -77,7 +80,10 @@ public class ProposalService : IProposalService
.Include(p => p.ApprovedBy) .Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct); .FirstOrDefaultAsync(p => p.Id == id, ct);
return proposal == null ? null : MapToResponse(proposal); if (proposal == null) return null;
if (_currentUser.Role == UserRole.Dispatcher && proposal.SubmittedById != _currentUser.UserId)
return null;
return MapToResponse(proposal);
} }
public async Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default) public async Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default)
@ -90,7 +96,7 @@ public class ProposalService : IProposalService
.Include(p => p.AssignedAdmin) .Include(p => p.AssignedAdmin)
.AsQueryable(); .AsQueryable();
if (filter.Mine) if (_currentUser.Role == UserRole.Dispatcher || filter.Mine)
{ {
query = query.Where(p => p.SubmittedById == _currentUser.UserId); query = query.Where(p => p.SubmittedById == _currentUser.UserId);
} }

View file

@ -1,10 +1,10 @@
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:15-alpine
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
POSTGRES_DB: proposalsystem POSTGRES_DB: proposals
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: localdev POSTGRES_PASSWORD: localdev
volumes: volumes:

View file

@ -28,6 +28,9 @@ const compute = new ComputeStack(app, 'proposal-system-compute', {
libraryBucket: foundation.libraryBucket, libraryBucket: foundation.libraryBucket,
jobsQueue: foundation.jobsQueue, jobsQueue: foundation.jobsQueue,
userPool: foundation.userPool, userPool: foundation.userPool,
alarmTopic: foundation.alarmTopic,
webClientId: foundation.webClientId,
mobileClientId: foundation.mobileClientId,
}); });
new FrontendStack(app, 'proposal-system-frontend', { new FrontendStack(app, 'proposal-system-frontend', {

View file

@ -2,16 +2,20 @@ import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam'; import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3'; import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
import * as bedrock from 'aws-cdk-lib/aws-bedrock'; import * as bedrock from 'aws-cdk-lib/aws-bedrock';
import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless'; import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless';
import * as logs from 'aws-cdk-lib/aws-logs'; import * as logs from 'aws-cdk-lib/aws-logs';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as cr from 'aws-cdk-lib/custom-resources'; import * as cr from 'aws-cdk-lib/custom-resources';
import { Construct } from 'constructs'; import { Construct } from 'constructs';
@ -24,6 +28,9 @@ export interface ComputeStackProps extends cdk.StackProps {
libraryBucket: s3.IBucket; libraryBucket: s3.IBucket;
jobsQueue: sqs.IQueue; jobsQueue: sqs.IQueue;
userPool: cognito.IUserPool; userPool: cognito.IUserPool;
alarmTopic: sns.ITopic;
webClientId: string;
mobileClientId: string;
} }
export class ComputeStack extends cdk.Stack { export class ComputeStack extends cdk.Stack {
@ -208,6 +215,11 @@ export class ComputeStack extends cdk.Stack {
LIBRARY_BUCKET: props.libraryBucket.bucketName, LIBRARY_BUCKET: props.libraryBucket.bucketName,
JOBS_QUEUE_URL: props.jobsQueue.queueUrl, JOBS_QUEUE_URL: props.jobsQueue.queueUrl,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
Auth__Authority: `https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`,
Auth__ClientId: props.webClientId,
Auth__CognitoDomain: `proposal-system-seahaven.auth.${this.region}.amazoncognito.com`,
COGNITO_WEB_CLIENT_ID: props.webClientId,
COGNITO_MOBILE_CLIENT_ID: props.mobileClientId,
}, },
tracing: lambda.Tracing.ACTIVE, tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.TWO_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
@ -225,6 +237,11 @@ export class ComputeStack extends cdk.Stack {
resources: [props.userPool.userPoolArn], resources: [props.userPool.userPoolArn],
})); }));
// Function URL for internal Lambda-to-API calls (bypasses API Gateway JWT authorizer)
const apiFunctionUrl = apiFunction.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
});
// API Gateway HTTP API // API Gateway HTTP API
const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', { const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', {
apiName: 'proposal-system-gateway', apiName: 'proposal-system-gateway',
@ -256,10 +273,29 @@ export class ComputeStack extends cdk.Stack {
apiFunction apiFunction
); );
const jwtAuthorizer = new apigatewayv2Authorizers.HttpJwtAuthorizer(
'CognitoAuthorizer',
`https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`,
{ jwtAudience: [props.webClientId, props.mobileClientId] },
);
httpApi.addRoutes({
path: '/api/health',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/api/auth/{proxy+}',
methods: [apigatewayv2.HttpMethod.POST],
integration: apiIntegration,
});
httpApi.addRoutes({ httpApi.addRoutes({
path: '/{proxy+}', path: '/{proxy+}',
methods: [apigatewayv2.HttpMethod.ANY], methods: [apigatewayv2.HttpMethod.ANY],
integration: apiIntegration, integration: apiIntegration,
authorizer: jwtAuthorizer,
}); });
// Python Lambda: Suggestions Engine // Python Lambda: Suggestions Engine
@ -277,7 +313,7 @@ export class ComputeStack extends cdk.Stack {
environment: { environment: {
KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId, KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId,
MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
API_BASE_URL: httpApi.apiEndpoint, API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
}, },
logRetention: logs.RetentionDays.TWO_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
@ -308,7 +344,7 @@ export class ComputeStack extends cdk.Stack {
environment: { environment: {
UPLOADS_BUCKET: props.uploadsBucket.bucketName, UPLOADS_BUCKET: props.uploadsBucket.bucketName,
MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
API_BASE_URL: httpApi.apiEndpoint, API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
}, },
logRetention: logs.RetentionDays.TWO_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
@ -335,7 +371,7 @@ export class ComputeStack extends cdk.Stack {
securityGroups: [props.lambdaSecurityGroup], securityGroups: [props.lambdaSecurityGroup],
environment: { environment: {
GENERATED_BUCKET: props.generatedBucket.bucketName, GENERATED_BUCKET: props.generatedBucket.bucketName,
API_BASE_URL: httpApi.apiEndpoint, API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
}, },
logRetention: logs.RetentionDays.TWO_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
@ -360,7 +396,7 @@ export class ComputeStack extends cdk.Stack {
LIBRARY_BUCKET: props.libraryBucket.bucketName, LIBRARY_BUCKET: props.libraryBucket.bucketName,
KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId, KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId,
DATA_SOURCE_ID: dataSource.attrDataSourceId, DATA_SOURCE_ID: dataSource.attrDataSourceId,
API_BASE_URL: httpApi.apiEndpoint, API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
}, },
logRetention: logs.RetentionDays.TWO_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
@ -376,6 +412,7 @@ export class ComputeStack extends cdk.Stack {
// SQS Event Sources with message filtering // SQS Event Sources with message filtering
suggestionsFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { suggestionsFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1, batchSize: 1,
reportBatchItemFailures: true,
filters: [ filters: [
lambda.FilterCriteria.filter({ lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('suggestions') }, body: { jobType: lambda.FilterRule.isEqual('suggestions') },
@ -385,6 +422,7 @@ export class ComputeStack extends cdk.Stack {
pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1, batchSize: 1,
reportBatchItemFailures: true,
filters: [ filters: [
lambda.FilterCriteria.filter({ lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('pdf-extract') }, body: { jobType: lambda.FilterRule.isEqual('pdf-extract') },
@ -394,6 +432,7 @@ export class ComputeStack extends cdk.Stack {
pdfGenerateFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { pdfGenerateFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1, batchSize: 1,
reportBatchItemFailures: true,
filters: [ filters: [
lambda.FilterCriteria.filter({ lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('pdf-generate') }, body: { jobType: lambda.FilterRule.isEqual('pdf-generate') },
@ -403,6 +442,7 @@ export class ComputeStack extends cdk.Stack {
libraryIngestFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { libraryIngestFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1, batchSize: 1,
reportBatchItemFailures: true,
filters: [ filters: [
lambda.FilterCriteria.filter({ lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('library-ingest') }, body: { jobType: lambda.FilterRule.isEqual('library-ingest') },
@ -410,6 +450,45 @@ export class ComputeStack extends cdk.Stack {
], ],
})); }));
// CloudWatch Alarms
const alarmAction = new cloudwatchActions.SnsAction(props.alarmTopic);
const lambdaFunctions = [
{ fn: apiFunction, name: 'api' },
{ fn: suggestionsFunction, name: 'suggestions' },
{ fn: pdfExtractFunction, name: 'pdf-extract' },
{ fn: pdfGenerateFunction, name: 'pdf-generate' },
{ fn: libraryIngestFunction, name: 'library-ingest' },
];
for (const { fn, name } of lambdaFunctions) {
const alarm = new cloudwatch.Alarm(this, `LambdaErrors-${name}`, {
alarmName: `proposal-system-${name}-errors`,
alarmDescription: `Lambda errors for ${name}`,
metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }),
threshold: 1,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
alarm.addAlarmAction(alarmAction);
}
const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', {
alarmName: 'proposal-system-api-5xx',
alarmDescription: 'API Gateway 5xx errors',
metric: new cloudwatch.Metric({
namespace: 'AWS/ApiGateway',
metricName: '5xx',
dimensionsMap: { ApiId: httpApi.httpApiId },
statistic: 'Sum',
period: cdk.Duration.minutes(5),
}),
threshold: 5,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
api5xxAlarm.addAlarmAction(alarmAction);
// Outputs // Outputs
new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint }); new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint });
new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn }); new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn });

View file

@ -6,6 +6,10 @@ import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as logs from 'aws-cdk-lib/aws-logs'; import * as logs from 'aws-cdk-lib/aws-logs';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import { Construct } from 'constructs'; import { Construct } from 'constructs';
export class FoundationStack extends cdk.Stack { export class FoundationStack extends cdk.Stack {
@ -17,6 +21,9 @@ export class FoundationStack extends cdk.Stack {
public readonly libraryBucket: s3.IBucket; public readonly libraryBucket: s3.IBucket;
public readonly jobsQueue: sqs.IQueue; public readonly jobsQueue: sqs.IQueue;
public readonly userPool: cognito.IUserPool; public readonly userPool: cognito.IUserPool;
public readonly alarmTopic: sns.ITopic;
public readonly webClientId: string;
public readonly mobileClientId: string;
constructor(scope: Construct, id: string, props?: cdk.StackProps) { constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props); super(scope, id, props);
@ -159,7 +166,7 @@ export class FoundationStack extends cdk.Stack {
this.jobsQueue = new sqs.Queue(this, 'JobsQueue', { this.jobsQueue = new sqs.Queue(this, 'JobsQueue', {
queueName: 'proposal-system-jobs', queueName: 'proposal-system-jobs',
visibilityTimeout: cdk.Duration.seconds(180), visibilityTimeout: cdk.Duration.seconds(720),
deadLetterQueue: { deadLetterQueue: {
queue: dlq, queue: dlq,
maxReceiveCount: 3, maxReceiveCount: 3,
@ -237,6 +244,8 @@ export class FoundationStack extends cdk.Stack {
}, },
}); });
this.webClientId = webClient.userPoolClientId;
// Mobile App Client (PKCE) // Mobile App Client (PKCE)
const mobileClient = userPool.addClient('MobileClient', { const mobileClient = userPool.addClient('MobileClient', {
userPoolClientName: 'proposal-system-mobile', userPoolClientName: 'proposal-system-mobile',
@ -256,6 +265,67 @@ export class FoundationStack extends cdk.Stack {
}, },
}); });
this.webClientId = webClient.userPoolClientId;
this.mobileClientId = mobileClient.userPoolClientId;
// SNS Alarm Topic
const alarmTopic = new sns.Topic(this, 'AlarmTopic', {
topicName: 'proposal-system-alarms',
displayName: 'Proposal System Alarms',
});
alarmTopic.addSubscription(
new snsSubscriptions.EmailSubscription('adam@seahavenind.com'),
);
this.alarmTopic = alarmTopic;
const alarmAction = new cloudwatchActions.SnsAction(alarmTopic);
// DLQ Alarm: any message landing in DLQ indicates a processing failure
const dlqAlarm = new cloudwatch.Alarm(this, 'DlqDepthAlarm', {
alarmName: 'proposal-system-dlq-depth',
alarmDescription: 'Messages in DLQ — SQS processing failures',
metric: dlq.metricApproximateNumberOfMessagesVisible({
period: cdk.Duration.minutes(1),
}),
threshold: 0,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
dlqAlarm.addAlarmAction(alarmAction);
// RDS Alarms
const rdsAlarms = [
new cloudwatch.Alarm(this, 'RdsCpuAlarm', {
alarmName: 'proposal-system-rds-cpu',
alarmDescription: 'RDS CPU utilization above 80%',
metric: dbInstance.metricCPUUtilization({ period: cdk.Duration.minutes(5) }),
threshold: 80,
evaluationPeriods: 3,
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
}),
new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', {
alarmName: 'proposal-system-rds-connections',
alarmDescription: 'RDS database connections above 80',
metric: dbInstance.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }),
threshold: 80,
evaluationPeriods: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}),
new cloudwatch.Alarm(this, 'RdsFreeStorageAlarm', {
alarmName: 'proposal-system-rds-free-storage',
alarmDescription: 'RDS free storage below 2 GB',
metric: dbInstance.metricFreeStorageSpace({ period: cdk.Duration.minutes(5) }),
threshold: 2_000_000_000,
comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
}),
];
for (const alarm of rdsAlarms) {
alarm.addAlarmAction(alarmAction);
}
// CloudWatch Log Groups // CloudWatch Log Groups
const logGroupNames = [ const logGroupNames = [
'proposal-system-api', 'proposal-system-api',
@ -283,5 +353,6 @@ export class FoundationStack extends cdk.Stack {
new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn }); new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn });
new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId }); new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId });
new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId }); new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId });
new cdk.CfnOutput(this, 'AlarmTopicArn', { value: alarmTopic.topicArn });
} }
} }

View file

@ -8,6 +8,7 @@ then triggers a KB sync.
import json import json
import logging import logging
import os import os
import time
from datetime import datetime from datetime import datetime
import boto3 import boto3
@ -41,12 +42,17 @@ def _get_api_key() -> str:
def handler(event, context): def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []): for record in event.get("Records", []):
body = json.loads(record["body"]) try:
payload = body.get("payload", body) body = json.loads(record["body"])
proposal_id = payload["proposalId"] payload = body.get("payload", body)
process_ingestion(proposal_id) proposal_id = payload["proposalId"]
return {"statusCode": 200} process_ingestion(proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def process_ingestion(proposal_id: str): def process_ingestion(proposal_id: str):
@ -67,10 +73,10 @@ def process_ingestion(proposal_id: str):
def fetch_proposal(proposal_id: str) -> dict | None: def fetch_proposal(proposal_id: str) -> dict | None:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}", f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -81,10 +87,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
def fetch_line_items(proposal_id: str) -> list[dict]: def fetch_line_items(proposal_id: str) -> list[dict]:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -198,3 +204,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -9,6 +9,7 @@ import json
import logging import logging
import os import os
import tempfile import tempfile
import time
import boto3 import boto3
import httpx import httpx
@ -41,19 +42,24 @@ def _get_api_key() -> str:
def handler(event, context): def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []): for record in event.get("Records", []):
body = json.loads(record["body"]) try:
payload = body.get("payload", body) body = json.loads(record["body"])
proposal_id = payload["proposalId"] payload = body.get("payload", body)
s3_key = payload.get("s3Key", "") proposal_id = payload["proposalId"]
vendor_proposal_id = payload.get("vendorProposalId", "") s3_key = payload.get("s3Key", "")
vendor_proposal_id = payload.get("vendorProposalId", "")
if not s3_key: if not s3_key:
logger.warning("No s3Key in payload for proposal %s", proposal_id) logger.warning("No s3Key in payload for proposal %s", proposal_id)
continue continue
process_pdf(proposal_id, s3_key, vendor_proposal_id) process_pdf(proposal_id, s3_key, vendor_proposal_id)
return {"statusCode": 200} except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str): def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str):
@ -299,7 +305,8 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
} }
try: try:
resp = httpx.put( resp = _retry_request(
"PUT",
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}", f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}",
json={ json={
"vendorName": extracted["vendorName"], "vendorName": extracted["vendorName"],
@ -308,7 +315,6 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
"processingStatus": "Complete", "processingStatus": "Complete",
}, },
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code not in (200, 204): if resp.status_code not in (200, 204):
logger.error( logger.error(
@ -322,11 +328,11 @@ def update_processing_status(vendor_proposal_id: str, status: str):
if not vendor_proposal_id: if not vendor_proposal_id:
return return
try: try:
httpx.put( _retry_request(
"PUT",
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status", f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status",
json={"processingStatus": status}, json={"processingStatus": status},
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
except Exception as e: except Exception as e:
logger.error("Error updating status: %s", e) logger.error("Error updating status: %s", e)
@ -338,3 +344,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -7,6 +7,7 @@ Triggered via SQS when an admin requests PDF generation.
import json import json
import logging import logging
import os import os
import time
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
@ -65,12 +66,17 @@ def _get_api_key() -> str:
def handler(event, context): def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []): for record in event.get("Records", []):
body = json.loads(record["body"]) try:
payload = body.get("payload", body) body = json.loads(record["body"])
proposal_id = payload["proposalId"] payload = body.get("payload", body)
generate_pdf(proposal_id) proposal_id = payload["proposalId"]
return {"statusCode": 200} generate_pdf(proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def generate_pdf(proposal_id: str): def generate_pdf(proposal_id: str):
@ -96,10 +102,10 @@ def generate_pdf(proposal_id: str):
def fetch_proposal(proposal_id: str) -> dict | None: def fetch_proposal(proposal_id: str) -> dict | None:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}", f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -110,10 +116,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
def fetch_line_items(proposal_id: str) -> list[dict]: def fetch_line_items(proposal_id: str) -> list[dict]:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -524,11 +530,11 @@ def upload_pdf(s3_key: str, pdf_bytes: bytes):
def register_pdf(proposal_id: str, s3_key: str): def register_pdf(proposal_id: str, s3_key: str):
try: try:
resp = httpx.post( resp = _retry_request(
"POST",
f"{API_BASE_URL}/api/generated-pdfs", f"{API_BASE_URL}/api/generated-pdfs",
json={"proposalId": proposal_id, "s3Key": s3_key}, json={"proposalId": proposal_id, "s3Key": s3_key},
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code not in (200, 201): if resp.status_code not in (200, 201):
logger.error("Failed to register PDF: %s %s", resp.status_code, resp.text) logger.error("Failed to register PDF: %s %s", resp.status_code, resp.text)
@ -542,3 +548,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -7,6 +7,7 @@ to generate line item suggestions for new proposals.
import json import json
import logging import logging
import os import os
import time
import boto3 import boto3
import httpx import httpx
@ -38,13 +39,18 @@ def _get_api_key() -> str:
def handler(event, context): def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []): for record in event.get("Records", []):
body = json.loads(record["body"]) try:
payload = body.get("payload", body) body = json.loads(record["body"])
proposal_id = payload["proposalId"] payload = body.get("payload", body)
trigger = payload.get("trigger", "generate") proposal_id = payload["proposalId"]
process_suggestion(proposal_id, trigger) trigger = payload.get("trigger", "generate")
return {"statusCode": 200} process_suggestion(proposal_id, trigger)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def process_suggestion(proposal_id: str, trigger: str): def process_suggestion(proposal_id: str, trigger: str):
@ -77,10 +83,10 @@ def process_suggestion(proposal_id: str, trigger: str):
def fetch_line_items(proposal_id: str) -> list[dict]: def fetch_line_items(proposal_id: str) -> list[dict]:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -91,10 +97,10 @@ def fetch_line_items(proposal_id: str) -> list[dict]:
def fetch_proposal(proposal_id: str) -> dict | None: def fetch_proposal(proposal_id: str) -> dict | None:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}", f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -268,7 +274,8 @@ def post_line_items(proposal_id: str, items: list[dict], existing_items: list[di
) )
try: try:
resp = httpx.put( resp = _retry_request(
"PUT",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
json={"lineItems": line_items_payload}, json={"lineItems": line_items_payload},
headers=_api_headers(), headers=_api_headers(),
@ -293,14 +300,14 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
continue continue
try: try:
httpx.post( _retry_request(
"POST",
f"{API_BASE_URL}/api/proposals/{proposal_id}/similar-references", f"{API_BASE_URL}/api/proposals/{proposal_id}/similar-references",
json={ json={
"referencedLibraryItemId": library_item_id, "referencedLibraryItemId": library_item_id,
"similarityScore": sp["score"], "similarityScore": sp["score"],
}, },
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
except Exception as e: except Exception as e:
logger.error("Error storing similar reference: %s", e) logger.error("Error storing similar reference: %s", e)
@ -312,3 +319,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -2,6 +2,7 @@ import UIKit
import React import React
import React_RCTAppDelegate import React_RCTAppDelegate
import ReactAppDependencyProvider import ReactAppDependencyProvider
import RCTLinking
@main @main
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -31,6 +32,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true return true
} }
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
return RCTLinkingManager.application(app, open: url, options: options)
}
} }
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {

View file

@ -11,9 +11,10 @@ import { theme } from './theme';
import { queryClient } from './lib/queryClient'; import { queryClient } from './lib/queryClient';
import { RootNavigator } from './navigation/RootNavigator'; import { RootNavigator } from './navigation/RootNavigator';
import { tokenStorage, userStorage } from './lib/storage'; import { tokenStorage, userStorage } from './lib/storage';
import { setUser, setLoading } from './store/slices/authSlice'; import { setUser, setLoading, logout as logoutAction } from './store/slices/authSlice';
import { processOfflineQueue } from './hooks/useOfflineDraft'; import { processOfflineQueue } from './hooks/useOfflineDraft';
import { proposalsApi, CreateProposalRequest } from './lib/api/proposals'; import { proposalsApi, CreateProposalRequest } from './lib/api/proposals';
import { onSessionExpired } from './lib/api/client';
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null }; state = { error: null as Error | null };
@ -56,6 +57,12 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
})(); })();
}, [dispatch]); }, [dispatch]);
useEffect(() => {
return onSessionExpired(() => {
dispatch(logoutAction());
});
}, [dispatch]);
useEffect(() => { useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => { const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) { if (state.isConnected) {

View file

@ -1,6 +1,6 @@
import { authorize, refresh, revoke } from 'react-native-app-auth'; import { authorize, refresh, revoke } from 'react-native-app-auth';
import Config from '../../config'; import Config from '../../config';
import apiClient from './client'; import apiClient, { registerTokenRefresh } from './client';
import { authenticateWithCredentials } from './cognito-auth'; import { authenticateWithCredentials } from './cognito-auth';
import { import {
tokenStorage, tokenStorage,
@ -12,11 +12,14 @@ import {
function parseUserFromIdToken(idToken: string): StoredUser { function parseUserFromIdToken(idToken: string): StoredUser {
const base64Url = idToken.split('.')[1]; const base64Url = idToken.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64)); const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
const payload = JSON.parse(atob(padded));
const groups: string[] = payload['cognito:groups'] || []; const groups: string[] = payload['cognito:groups'] || [];
const role = groups.includes('admins') || groups.includes('sysadmins') const role = groups.includes('sysadmins')
? 'Admin' ? 'SysAdmin'
: 'Dispatcher'; : groups.includes('admins')
? 'Admin'
: 'Dispatcher';
return { return {
id: payload.sub, id: payload.sub,
email: payload.email, email: payload.email,
@ -117,3 +120,5 @@ export const authApi = {
return res.data; return res.data;
}, },
}; };
registerTokenRefresh(() => authApi.refreshTokens());

View file

@ -2,6 +2,41 @@ import axios from 'axios';
import Config from '../../config'; import Config from '../../config';
import { tokenStorage } from '../storage'; import { tokenStorage } from '../storage';
type RefreshFn = () => Promise<void>;
let _refreshTokens: RefreshFn | null = null;
export function registerTokenRefresh(fn: RefreshFn) {
_refreshTokens = fn;
}
type SessionExpiredListener = () => void;
const sessionExpiredListeners: SessionExpiredListener[] = [];
export function onSessionExpired(listener: SessionExpiredListener): () => void {
sessionExpiredListeners.push(listener);
return () => {
const idx = sessionExpiredListeners.indexOf(listener);
if (idx >= 0) sessionExpiredListeners.splice(idx, 1);
};
}
function emitSessionExpired() {
sessionExpiredListeners.forEach((fn) => fn());
}
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
function subscribeToRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb);
}
function onRefreshComplete(token: string) {
refreshSubscribers.forEach((cb) => cb(token));
refreshSubscribers = [];
}
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: Config.API_URL, baseURL: Config.API_URL,
headers: { headers: {
@ -23,15 +58,40 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
if (error.response) { if (error.response) {
const { status, data } = error.response; const { status, data, config: originalRequest } = error.response;
if (status === 401) { if (status === 401 && !originalRequest._retry) {
tokenStorage.clear(); originalRequest._retry = true;
return Promise.reject(
new Error('Session expired. Please log in again.'), if (isRefreshing) {
); return new Promise((resolve) => {
subscribeToRefresh((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(originalRequest));
});
});
}
isRefreshing = true;
try {
if (!_refreshTokens) throw new Error('No refresh handler');
await _refreshTokens();
const tokens = await tokenStorage.get();
const newToken = tokens?.accessToken ?? '';
onRefreshComplete(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} catch {
await tokenStorage.clear();
emitSessionExpired();
return Promise.reject(
new Error('Session expired. Please log in again.'),
);
} finally {
isRefreshing = false;
}
} }
if (status === 403) { if (status === 403) {

View file

@ -32,6 +32,14 @@ export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<str
}; };
}, [search]); }, [search]);
const prevExtraKey = useRef(extraKey);
useEffect(() => {
if (prevExtraKey.current !== extraKey) {
prevExtraKey.current = extraKey;
setPage(1);
}
}, [extraKey]);
const reload = useCallback(() => { const reload = useCallback(() => {
setLoading(true); setLoading(true);
setErr(''); setErr('');

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate, useBlocker } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { import {
Box, Box,
@ -83,6 +83,23 @@ export default function AdminWorkspace() {
} }
}, [proposal]); }, [proposal]);
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
const blocker = useBlocker(
useCallback(
({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) =>
dirty && currentLocation.pathname !== nextLocation.pathname,
[dirty],
),
);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
await adminApi.updateProposal(id!, { refinedScope }); await adminApi.updateProposal(id!, { refinedScope });
@ -391,6 +408,20 @@ export default function AdminWorkspace() {
</CardContent> </CardContent>
</Card> </Card>
{/* Unsaved Changes Navigation Guard */}
<Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()}>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogContent>
<Typography>You have unsaved changes. Are you sure you want to leave?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => blocker.reset?.()}>Stay</Button>
<Button variant="contained" color="error" onClick={() => blocker.proceed?.()}>
Discard & Leave
</Button>
</DialogActions>
</Dialog>
{/* Approve Confirmation Dialog */} {/* Approve Confirmation Dialog */}
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}> <Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
<DialogTitle>Approve Proposal</DialogTitle> <DialogTitle>Approve Proposal</DialogTitle>

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material'; import { Alert, Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material';
import GoogleIcon from '@mui/icons-material/Google'; import GoogleIcon from '@mui/icons-material/Google';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import EngineeringIcon from '@mui/icons-material/Engineering'; import EngineeringIcon from '@mui/icons-material/Engineering';
@ -28,7 +28,7 @@ function buildLoginUrl(): string {
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { isAuthenticated } = useAuth(); const { isAuthenticated, error } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const isDevMode = !CLIENT_ID; const isDevMode = !CLIENT_ID;
@ -103,6 +103,11 @@ export default function LoginPage() {
</Typography> </Typography>
</Box> </Box>
<CardContent sx={{ p: '30px', textAlign: 'center' }}> <CardContent sx={{ p: '30px', textAlign: 'center' }}>
{error && (
<Alert severity="error" sx={{ mb: 2, textAlign: 'left' }}>
{error}
</Alert>
)}
{isDevMode ? ( {isDevMode ? (
<> <>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>