mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 12:48:57 +00:00
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:
commit
52da67494e
24 changed files with 577 additions and 99 deletions
25
README.md
25
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue