commit 0b055b3ad901863b6dbfd001f1b8f916b3cda023 Author: Adam Moussa Date: Sat May 16 18:40:46 2026 -0400 Initial scaffold: monorepo structure, CDK stacks, CI/CD, domain model Phase 0 of proposal-system: complete project setup including: - CDK infrastructure (3 stacks: foundation, compute, frontend) - .NET 8 solution with Clean Architecture (Domain, Application, Infrastructure, Api) - EF Core data model (PostgreSQL) with all entities - Python Lambda placeholders (pdf-extract, pdf-generate, library-ingest) - React 19 web frontend scaffold (Vite + MUI) - React Native mobile placeholder - Shared TypeScript API contracts - GitHub Actions CI/CD (ci.yaml + deploy.yaml) - OIDC deploy role (githubdeploy-proposal-system) - Dependabot configuration - Cognito User Pool with Google OAuth, PKCE clients, groups diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8a4af3a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: /infra + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: npm + directory: /web + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: nuget + directory: /api + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: pip + directory: /lambdas/pdf-extract + schedule: + interval: weekly + open-pull-requests-limit: 3 + + - package-ecosystem: pip + directory: /lambdas/pdf-generate + schedule: + interval: weekly + open-pull-requests-limit: 3 + + - package-ecosystem: pip + directory: /lambdas/library-ingest + schedule: + interval: weekly + open-pull-requests-limit: 3 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..63a1e52 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,76 @@ +name: CI + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + dotnet: + name: .NET Build & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - run: dotnet restore ProposalSystem.sln + - run: dotnet build ProposalSystem.sln --no-restore --configuration Release + - run: dotnet test ProposalSystem.sln --no-build --configuration Release + + web: + name: Web Frontend Check + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + - run: npx tsc --noEmit + + python: + name: Python Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - run: pip install ruff + - run: ruff check lambdas/ + - run: ruff format --check lambdas/ + + infra: + name: CDK Synth + runs-on: ubuntu-latest + defaults: + run: + working-directory: infra + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: infra/package-lock.json + + - run: npm ci + - run: npx cdk synth diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..ddb2dd5 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,89 @@ +name: Deploy + +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + name: Deploy to AWS + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: infra/package-lock.json + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + + # Build .NET API + - name: Build API + working-directory: api + run: | + dotnet publish src/ProposalSystem.Api/ProposalSystem.Api.csproj \ + --configuration Release \ + --runtime linux-arm64 \ + --self-contained false \ + --output src/ProposalSystem.Api/bin/Release/net8.0/linux-arm64/publish + + # Install Python dependencies for Lambdas + - name: Install PDF Extract deps + working-directory: lambdas/pdf-extract + run: pip install -r requirements.txt -t . + + - name: Install PDF Generate deps + working-directory: lambdas/pdf-generate + run: pip install -r requirements.txt -t . + + - name: Install Library Ingest deps + working-directory: lambdas/library-ingest + run: pip install -r requirements.txt -t . + + # CDK Deploy + - name: CDK Deploy + working-directory: infra + run: | + npm ci + npx cdk deploy --all --require-approval never + + # CloudFront Invalidation (after frontend deploy) + - name: Build Web Frontend + working-directory: web + run: | + npm ci + npm run build + + - name: Deploy Web to S3 + run: | + BUCKET=$(aws cloudformation describe-stacks \ + --stack-name proposal-system-frontend \ + --query "Stacks[0].Outputs[?OutputKey=='SiteBucketName'].OutputValue" \ + --output text) + aws s3 sync web/dist "s3://$BUCKET" --delete + + - name: Invalidate CloudFront + run: | + DIST_ID=$(aws cloudformation describe-stacks \ + --stack-name proposal-system-frontend \ + --query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \ + --output text) + aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45fc845 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# .NET +**/bin/Debug/ +**/bin/Release/ +**/obj/ +*.user +*.suo +*.vs/ + +# Node +node_modules/ +dist/ +.vite/ + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# CDK +cdk.out/ + +# SAM +.aws-sam/ + +# Environment +.env +.env.* +!.env.example + +# IDE +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Test +coverage/ +TestResults/ + +# Build artifacts +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..65bde41 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Proposal Management System + +AI-powered proposal generation and management system for Sea Haven Industries. Enables dispatchers to submit proposal requests, uses Bedrock RAG to auto-generate draft line items from historical data, and provides admins with a pricing/approval workspace with professional PDF output. + +## Architecture + +| Layer | Technology | +|---|---| +| Backend API | .NET 8 / ASP.NET Core on Lambda (`Amazon.Lambda.AspNetCoreServer`) | +| Database | RDS PostgreSQL 15 + EF Core | +| Frontend Web | React 19 + MUI v7 + TypeScript | +| Mobile | React Native (iOS-first) | +| IaC | CDK (TypeScript) - 3 stacks | +| AI/RAG | Bedrock Knowledge Base + Claude (Bedrock Runtime) | +| PDF Processing | Python 3.12 Lambdas | +| Auth | Cognito User Pool + Google OAuth | +| Storage | S3 (PDFs, attachments, library) | +| Notifications | Slack Bot API | + +## AWS Resources + +- **Stack prefix:** `proposal-system-*` +- **Account:** 328440206208 +- **Region:** us-east-1 + +| Resource | Name | +|---|---| +| Lambda (.NET 8) | `proposal-system-api` | +| API Gateway HTTP API | `proposal-system-gateway` | +| RDS PostgreSQL | `proposal-system-db` | +| S3 Buckets | `proposal-system-uploads`, `proposal-system-generated`, `proposal-system-library` | +| Bedrock Knowledge Base | `proposal-system-kb` | +| Lambda (Python) | `proposal-system-pdf-extract`, `proposal-system-pdf-generate`, `proposal-system-library-ingest` | +| Cognito User Pool | `proposal-system-auth` | +| CloudFront | `proposal-system-web` | +| SQS Queue | `proposal-system-jobs` + DLQ | + +## Repository Structure + +``` +proposal-system/ +├── infra/ # CDK app (TypeScript) - 3 stacks +├── api/ # .NET 8 Web API (Lambda-hosted) +├── web/ # React 19 + MUI frontend +├── mobile/ # React Native app (iOS) +├── lambdas/ # Python 3.12 processing functions +├── shared/ # Shared TypeScript types/contracts +└── .github/ # CI/CD workflows +``` + +## Local Development + +### Prerequisites + +- .NET 8 SDK +- Node.js 24 +- Python 3.12 +- AWS CDK CLI (`npm install -g aws-cdk@2.253.1`) + +### Backend API + +```bash +cd api +dotnet restore +dotnet run --project src/ProposalSystem.Api +``` + +### Web Frontend + +```bash +cd web +npm install +npm run dev +``` + +### Infrastructure + +```bash +cd infra +npm install +npx cdk synth +npx cdk deploy --all +``` + +## CI/CD + +- **CI:** Runs on pull requests to `main` (dotnet build/test, tsc, ruff, cdk synth) +- **Deploy:** Runs on push to `main` (cdk deploy, frontend S3 sync, CloudFront invalidation) +- **OIDC Role:** `githubdeploy-proposal-system` + +## Data Flow + +1. Dispatcher submits proposal request (web or mobile) +2. API creates proposal record, publishes SQS message +3. If vendor PDF attached: `pdf-extract` Lambda parses and structures data +4. Suggestion engine queries Bedrock KB for similar proposals, generates line items via Claude +5. Admin reviews/edits line items in workspace +6. On approval: `pdf-generate` Lambda creates branded PDF +7. On send: `library-ingest` Lambda adds to KB for future matching +8. Slack notifications at key status transitions diff --git a/api/ProposalSystem.sln b/api/ProposalSystem.sln new file mode 100644 index 0000000..f34f266 --- /dev/null +++ b/api/ProposalSystem.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Api", "src\ProposalSystem.Api\ProposalSystem.Api.csproj", "{A1B2C3D4-1111-2222-3333-444455556666}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Domain", "src\ProposalSystem.Domain\ProposalSystem.Domain.csproj", "{A1B2C3D4-1111-2222-3333-444455557777}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Application", "src\ProposalSystem.Application\ProposalSystem.Application.csproj", "{A1B2C3D4-1111-2222-3333-444455558888}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Infrastructure", "src\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj", "{A1B2C3D4-1111-2222-3333-444455559999}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455559999}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455559999}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/api/global.json b/api/global.json new file mode 100644 index 0000000..ae9f38c --- /dev/null +++ b/api/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.400", + "rollForward": "latestFeature" + } +} diff --git a/api/src/ProposalSystem.Api/Controllers/HealthController.cs b/api/src/ProposalSystem.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..e631afc --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/HealthController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class HealthController : ControllerBase +{ + [HttpGet] + public IActionResult Get() => Ok(new { status = "healthy", timestamp = DateTime.UtcNow }); +} diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs new file mode 100644 index 0000000..a6f7942 --- /dev/null +++ b/api/src/ProposalSystem.Api/Program.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Infrastructure.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddAuthentication("Bearer") + .AddJwtBearer(options => + { + options.Authority = builder.Configuration["Auth:Authority"]; + options.Audience = builder.Configuration["Auth:Audience"]; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddHealthChecks() + .AddDbContextCheck(); + +builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapHealthChecks("/api/health"); + +app.Run(); diff --git a/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj new file mode 100644 index 0000000..17e9175 --- /dev/null +++ b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + true + Lambda + true + true + + + + + + + + + + + + + + diff --git a/api/src/ProposalSystem.Api/appsettings.json b/api/src/ProposalSystem.Api/appsettings.json new file mode 100644 index 0000000..38ef245 --- /dev/null +++ b/api/src/ProposalSystem.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Auth": { + "Authority": "", + "Audience": "" + }, + "ConnectionStrings": { + "DefaultConnection": "" + } +} diff --git a/api/src/ProposalSystem.Application/Interfaces/IProposalRepository.cs b/api/src/ProposalSystem.Application/Interfaces/IProposalRepository.cs new file mode 100644 index 0000000..8465b74 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IProposalRepository.cs @@ -0,0 +1,11 @@ +using ProposalSystem.Domain.Entities; + +namespace ProposalSystem.Application.Interfaces; + +public interface IProposalRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task CreateAsync(Proposal proposal, CancellationToken ct = default); + Task UpdateAsync(Proposal proposal, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/ProposalSystem.Application.csproj b/api/src/ProposalSystem.Application/ProposalSystem.Application.csproj new file mode 100644 index 0000000..592a41b --- /dev/null +++ b/api/src/ProposalSystem.Application/ProposalSystem.Application.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/api/src/ProposalSystem.Domain/Entities/AuditLog.cs b/api/src/ProposalSystem.Domain/Entities/AuditLog.cs new file mode 100644 index 0000000..c5d9e0e --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/AuditLog.cs @@ -0,0 +1,32 @@ +namespace ProposalSystem.Domain.Entities; + +public enum AuditAction +{ + Submit, + Edit, + Approve, + Download, + Regenerate, + Revise, + AssignAdmin, + EditLineItem, + EditScope, + GeneratePDF, + MarkSent, + CreateRevision, + UpdateRole +} + +public class AuditLog +{ + public Guid Id { get; set; } + public Guid? ProposalId { get; set; } + public Guid UserId { get; set; } + public AuditAction Action { get; set; } + public string? Details { get; set; } + public DateTime Timestamp { get; set; } + public string? IpAddress { get; set; } + + public Proposal? Proposal { get; set; } + public User? User { get; set; } +} diff --git a/api/src/ProposalSystem.Domain/Entities/Customer.cs b/api/src/ProposalSystem.Domain/Entities/Customer.cs new file mode 100644 index 0000000..468145d --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/Customer.cs @@ -0,0 +1,10 @@ +namespace ProposalSystem.Domain.Entities; + +public class Customer +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Addresses { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/api/src/ProposalSystem.Domain/Entities/GeneratedPdf.cs b/api/src/ProposalSystem.Domain/Entities/GeneratedPdf.cs new file mode 100644 index 0000000..f58377e --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/GeneratedPdf.cs @@ -0,0 +1,14 @@ +namespace ProposalSystem.Domain.Entities; + +public class GeneratedPdf +{ + public Guid Id { get; set; } + public Guid ProposalId { get; set; } + public int Revision { get; set; } + public string S3Key { get; set; } = string.Empty; + public DateTime GeneratedAt { get; set; } + public Guid GeneratedById { get; set; } + + public Proposal? Proposal { get; set; } + public User? GeneratedBy { get; set; } +} diff --git a/api/src/ProposalSystem.Domain/Entities/LineItem.cs b/api/src/ProposalSystem.Domain/Entities/LineItem.cs new file mode 100644 index 0000000..e876e18 --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/LineItem.cs @@ -0,0 +1,34 @@ +namespace ProposalSystem.Domain.Entities; + +public enum PricingMode +{ + UnitPrice, + TotalPrice, + Both +} + +public enum LineItemSource +{ + AI, + Vendor, + Manual, + Historical +} + +public class LineItem +{ + public Guid Id { get; set; } + public Guid ProposalId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public string Unit { get; set; } = string.Empty; + public decimal? UnitPrice { get; set; } + public decimal TotalPrice { get; set; } + public PricingMode PricingMode { get; set; } + public int SortOrder { get; set; } + public LineItemSource Source { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + public Proposal? Proposal { get; set; } +} diff --git a/api/src/ProposalSystem.Domain/Entities/Proposal.cs b/api/src/ProposalSystem.Domain/Entities/Proposal.cs new file mode 100644 index 0000000..9674287 --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/Proposal.cs @@ -0,0 +1,60 @@ +namespace ProposalSystem.Domain.Entities; + +public enum ProposalStatus +{ + Draft, + InReview, + Approved, + Sent, + Revised +} + +public enum ServiceCategory +{ + HVAC, + Plumbing, + Electrical, + General, + Renovation +} + +public enum Priority +{ + Standard, + Urgent, + Emergency +} + +public class Proposal +{ + public Guid Id { get; set; } + public string ProposalNumber { get; set; } = string.Empty; + public string WorkOrderNumber { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string CustomerAddress { get; set; } = string.Empty; + public string ScopeOfWork { get; set; } = string.Empty; + public string? RefinedScope { get; set; } + public ServiceCategory ServiceCategory { get; set; } + public Priority Priority { get; set; } + public ProposalStatus Status { get; set; } + public decimal TotalBidAmount { get; set; } + public decimal? VendorTotalCost { get; set; } + public string Notes { get; set; } = string.Empty; + public Guid SubmittedById { get; set; } + public DateTime SubmittedAt { get; set; } + public Guid? AssignedAdminId { get; set; } + public Guid? ApprovedById { get; set; } + public DateTime? ApprovedAt { get; set; } + public DateTime? SentAt { get; set; } + public int CurrentRevision { get; set; } = 1; + public Guid? ParentProposalId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + public User? SubmittedBy { get; set; } + public User? AssignedAdmin { get; set; } + public User? ApprovedBy { get; set; } + public Proposal? ParentProposal { get; set; } + public ICollection LineItems { get; set; } = new List(); + public ICollection VendorProposals { get; set; } = new List(); +} diff --git a/api/src/ProposalSystem.Domain/Entities/SimilarProposalReference.cs b/api/src/ProposalSystem.Domain/Entities/SimilarProposalReference.cs new file mode 100644 index 0000000..773c3f8 --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/SimilarProposalReference.cs @@ -0,0 +1,14 @@ +namespace ProposalSystem.Domain.Entities; + +public class SimilarProposalReference +{ + public Guid Id { get; set; } + public Guid ProposalId { get; set; } + public string ReferencedLibraryItemId { get; set; } = string.Empty; + public float SimilarityScore { get; set; } + public DateTime ReferencedAt { get; set; } + public Guid ReferencedById { get; set; } + + public Proposal? Proposal { get; set; } + public User? ReferencedBy { get; set; } +} diff --git a/api/src/ProposalSystem.Domain/Entities/User.cs b/api/src/ProposalSystem.Domain/Entities/User.cs new file mode 100644 index 0000000..d808ae5 --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/User.cs @@ -0,0 +1,20 @@ +namespace ProposalSystem.Domain.Entities; + +public enum UserRole +{ + Dispatcher, + Admin, + SysAdmin +} + +public class User +{ + public Guid Id { get; set; } + public string CognitoSub { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public UserRole Role { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/api/src/ProposalSystem.Domain/Entities/VendorProposal.cs b/api/src/ProposalSystem.Domain/Entities/VendorProposal.cs new file mode 100644 index 0000000..f59a7b6 --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/VendorProposal.cs @@ -0,0 +1,24 @@ +namespace ProposalSystem.Domain.Entities; + +public enum ProcessingStatus +{ + Pending, + Processing, + Complete, + Failed +} + +public class VendorProposal +{ + public Guid Id { get; set; } + public Guid ProposalId { get; set; } + public string VendorName { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string S3Key { get; set; } = string.Empty; + public string? ExtractedData { get; set; } + public decimal TotalVendorCost { get; set; } + public DateTime UploadedAt { get; set; } + public ProcessingStatus ProcessingStatus { get; set; } + + public Proposal? Proposal { get; set; } +} diff --git a/api/src/ProposalSystem.Domain/ProposalSystem.Domain.csproj b/api/src/ProposalSystem.Domain/ProposalSystem.Domain.csproj new file mode 100644 index 0000000..5d6e827 --- /dev/null +++ b/api/src/ProposalSystem.Domain/ProposalSystem.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs new file mode 100644 index 0000000..70b882e --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Domain.Entities; + +namespace ProposalSystem.Infrastructure.Data; + +public class ProposalDbContext : DbContext +{ + public ProposalDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Proposals => Set(); + public DbSet LineItems => Set(); + public DbSet VendorProposals => Set(); + public DbSet Customers => Set(); + public DbSet Users => Set(); + public DbSet AuditLogs => Set(); + public DbSet SimilarProposalReferences => Set(); + public DbSet GeneratedPdfs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.ProposalNumber).IsUnique(); + entity.Property(e => e.TotalBidAmount).HasPrecision(18, 2); + entity.Property(e => e.VendorTotalCost).HasPrecision(18, 2); + entity.Property(e => e.Status).HasConversion(); + entity.Property(e => e.ServiceCategory).HasConversion(); + entity.Property(e => e.Priority).HasConversion(); + + entity.HasOne(e => e.SubmittedBy).WithMany().HasForeignKey(e => e.SubmittedById).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.AssignedAdmin).WithMany().HasForeignKey(e => e.AssignedAdminId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.ApprovedBy).WithMany().HasForeignKey(e => e.ApprovedById).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.ParentProposal).WithMany().HasForeignKey(e => e.ParentProposalId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Quantity).HasPrecision(18, 4); + entity.Property(e => e.UnitPrice).HasPrecision(18, 2); + entity.Property(e => e.TotalPrice).HasPrecision(18, 2); + entity.Property(e => e.PricingMode).HasConversion(); + entity.Property(e => e.Source).HasConversion(); + + entity.HasOne(e => e.Proposal).WithMany(p => p.LineItems).HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.TotalVendorCost).HasPrecision(18, 2); + entity.Property(e => e.ProcessingStatus).HasConversion(); + entity.Property(e => e.ExtractedData).HasColumnType("jsonb"); + + entity.HasOne(e => e.Proposal).WithMany(p => p.VendorProposals).HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Addresses).HasColumnType("jsonb"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.CognitoSub).IsUnique(); + entity.HasIndex(e => e.Email).IsUnique(); + entity.Property(e => e.Role).HasConversion(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Action).HasConversion(); + entity.Property(e => e.Details).HasColumnType("jsonb"); + entity.HasIndex(e => e.ProposalId); + entity.HasIndex(e => e.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj new file mode 100644 index 0000000..5b7e787 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/infra/bin/app.ts b/infra/bin/app.ts new file mode 100644 index 0000000..759b825 --- /dev/null +++ b/infra/bin/app.ts @@ -0,0 +1,37 @@ +import * as cdk from 'aws-cdk-lib'; +import { FoundationStack } from '../lib/foundation-stack'; +import { ComputeStack } from '../lib/compute-stack'; +import { FrontendStack } from '../lib/frontend-stack'; + +const app = new cdk.App(); + +const env: cdk.Environment = { + account: '328440206208', + region: 'us-east-1', +}; + +const foundation = new FoundationStack(app, 'proposal-system-foundation', { + stackName: 'proposal-system-foundation', + env, + description: 'Proposal System - VPC, RDS, S3, SQS, Cognito', +}); + +const compute = new ComputeStack(app, 'proposal-system-compute', { + stackName: 'proposal-system-compute', + env, + description: 'Proposal System - API Lambda, Python Lambdas, Bedrock KB', + vpc: foundation.vpc, + lambdaSecurityGroup: foundation.lambdaSecurityGroup, + dbSecret: foundation.dbSecret, + uploadsBucket: foundation.uploadsBucket, + generatedBucket: foundation.generatedBucket, + libraryBucket: foundation.libraryBucket, + jobsQueue: foundation.jobsQueue, + userPool: foundation.userPool, +}); + +new FrontendStack(app, 'proposal-system-frontend', { + stackName: 'proposal-system-frontend', + env, + description: 'Proposal System - CloudFront + S3 web hosting', +}); diff --git a/infra/cdk.context.json b/infra/cdk.context.json new file mode 100644 index 0000000..2145e1d --- /dev/null +++ b/infra/cdk.context.json @@ -0,0 +1,10 @@ +{ + "availability-zones:account=328440206208:region=us-east-1": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + "us-east-1e", + "us-east-1f" + ] +} diff --git a/infra/cdk.json b/infra/cdk.json new file mode 100644 index 0000000..72066ba --- /dev/null +++ b/infra/cdk.json @@ -0,0 +1,21 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "dist" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws"] + } +} diff --git a/infra/lib/compute-stack.ts b/infra/lib/compute-stack.ts new file mode 100644 index 0000000..c5e0553 --- /dev/null +++ b/infra/lib/compute-stack.ts @@ -0,0 +1,199 @@ +import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; +import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as sqs from 'aws-cdk-lib/aws-sqs'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; +import { Construct } from 'constructs'; + +export interface ComputeStackProps extends cdk.StackProps { + vpc: ec2.IVpc; + lambdaSecurityGroup: ec2.ISecurityGroup; + dbSecret: secretsmanager.ISecret; + uploadsBucket: s3.IBucket; + generatedBucket: s3.IBucket; + libraryBucket: s3.IBucket; + jobsQueue: sqs.IQueue; + userPool: cognito.IUserPool; +} + +export class ComputeStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: ComputeStackProps) { + super(scope, id, props); + + const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }; + + // .NET 8 API Lambda + const apiFunction = new lambda.Function(this, 'ApiFunction', { + functionName: 'proposal-system-api', + runtime: lambda.Runtime.DOTNET_8, + architecture: lambda.Architecture.ARM_64, + handler: 'ProposalSystem.Api', + code: lambda.Code.fromAsset('../api/src/ProposalSystem.Api/bin/Release/net8.0/linux-arm64/publish'), + memorySize: 1024, + timeout: cdk.Duration.seconds(30), + vpc: props.vpc, + vpcSubnets: privateSubnets, + securityGroups: [props.lambdaSecurityGroup], + environment: { + ASPNETCORE_ENVIRONMENT: 'Production', + DB_SECRET_ARN: props.dbSecret.secretArn, + UPLOADS_BUCKET: props.uploadsBucket.bucketName, + GENERATED_BUCKET: props.generatedBucket.bucketName, + LIBRARY_BUCKET: props.libraryBucket.bucketName, + JOBS_QUEUE_URL: props.jobsQueue.queueUrl, + }, + tracing: lambda.Tracing.ACTIVE, + }); + + // API Lambda permissions + props.dbSecret.grantRead(apiFunction); + props.uploadsBucket.grantReadWrite(apiFunction); + props.generatedBucket.grantRead(apiFunction); + props.jobsQueue.grantSendMessages(apiFunction); + + apiFunction.addToRolePolicy(new iam.PolicyStatement({ + actions: ['cognito-idp:AdminGetUser', 'cognito-idp:AdminListGroupsForUser'], + resources: [props.userPool.userPoolArn], + })); + + // API Gateway HTTP API + const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', { + apiName: 'proposal-system-gateway', + corsPreflight: { + allowOrigins: [ + 'https://proposals.seahaven.com', + 'http://localhost:5173', + ], + allowMethods: [ + apigatewayv2.CorsHttpMethod.GET, + apigatewayv2.CorsHttpMethod.POST, + apigatewayv2.CorsHttpMethod.PUT, + apigatewayv2.CorsHttpMethod.DELETE, + apigatewayv2.CorsHttpMethod.OPTIONS, + ], + allowHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'], + maxAge: cdk.Duration.hours(1), + }, + }); + + const apiIntegration = new apigatewayv2Integrations.HttpLambdaIntegration( + 'ApiIntegration', + apiFunction + ); + + httpApi.addRoutes({ + path: '/{proxy+}', + methods: [apigatewayv2.HttpMethod.ANY], + integration: apiIntegration, + }); + + // Python Lambda: PDF Extract + const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', { + functionName: 'proposal-system-pdf-extract', + runtime: lambda.Runtime.PYTHON_3_12, + architecture: lambda.Architecture.ARM_64, + handler: 'app.handler', + code: lambda.Code.fromAsset('../lambdas/pdf-extract'), + memorySize: 1024, + timeout: cdk.Duration.seconds(120), + vpc: props.vpc, + vpcSubnets: privateSubnets, + securityGroups: [props.lambdaSecurityGroup], + environment: { + UPLOADS_BUCKET: props.uploadsBucket.bucketName, + API_BASE_URL: httpApi.apiEndpoint, + }, + }); + + props.uploadsBucket.grantRead(pdfExtractFunction); + pdfExtractFunction.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:InvokeModel'], + resources: ['*'], + })); + + // Python Lambda: PDF Generate + const pdfGenerateFunction = new lambda.Function(this, 'PdfGenerateFunction', { + functionName: 'proposal-system-pdf-generate', + runtime: lambda.Runtime.PYTHON_3_12, + architecture: lambda.Architecture.ARM_64, + handler: 'app.handler', + code: lambda.Code.fromAsset('../lambdas/pdf-generate'), + memorySize: 512, + timeout: cdk.Duration.seconds(30), + vpc: props.vpc, + vpcSubnets: privateSubnets, + securityGroups: [props.lambdaSecurityGroup], + environment: { + GENERATED_BUCKET: props.generatedBucket.bucketName, + API_BASE_URL: httpApi.apiEndpoint, + }, + }); + + props.generatedBucket.grantWrite(pdfGenerateFunction); + + // Python Lambda: Library Ingest + const libraryIngestFunction = new lambda.Function(this, 'LibraryIngestFunction', { + functionName: 'proposal-system-library-ingest', + runtime: lambda.Runtime.PYTHON_3_12, + architecture: lambda.Architecture.ARM_64, + handler: 'app.handler', + code: lambda.Code.fromAsset('../lambdas/library-ingest'), + memorySize: 512, + timeout: cdk.Duration.seconds(60), + vpc: props.vpc, + vpcSubnets: privateSubnets, + securityGroups: [props.lambdaSecurityGroup], + environment: { + LIBRARY_BUCKET: props.libraryBucket.bucketName, + API_BASE_URL: httpApi.apiEndpoint, + }, + }); + + props.libraryBucket.grantWrite(libraryIngestFunction); + libraryIngestFunction.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:StartIngestionJob'], + resources: ['*'], + })); + + // SQS Event Source for Python Lambdas + // All three consume from the same queue, routed by message attributes + // For now, use a single consumer that routes internally + // TODO: Phase 4 will refine this to use message filtering or separate queues per function + pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { + batchSize: 1, + filters: [ + lambda.FilterCriteria.filter({ + body: { jobType: lambda.FilterRule.isEqual('pdf-extract') }, + }), + ], + })); + + pdfGenerateFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { + batchSize: 1, + filters: [ + lambda.FilterCriteria.filter({ + body: { jobType: lambda.FilterRule.isEqual('pdf-generate') }, + }), + ], + })); + + libraryIngestFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { + batchSize: 1, + filters: [ + lambda.FilterCriteria.filter({ + body: { jobType: lambda.FilterRule.isEqual('library-ingest') }, + }), + ], + })); + + // Outputs + new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint }); + new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn }); + } +} diff --git a/infra/lib/foundation-stack.ts b/infra/lib/foundation-stack.ts new file mode 100644 index 0000000..4040621 --- /dev/null +++ b/infra/lib/foundation-stack.ts @@ -0,0 +1,282 @@ +import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as rds from 'aws-cdk-lib/aws-rds'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as sqs from 'aws-cdk-lib/aws-sqs'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { Construct } from 'constructs'; + +export class FoundationStack extends cdk.Stack { + public readonly vpc: ec2.IVpc; + public readonly lambdaSecurityGroup: ec2.ISecurityGroup; + public readonly dbSecret: secretsmanager.ISecret; + public readonly uploadsBucket: s3.IBucket; + public readonly generatedBucket: s3.IBucket; + public readonly libraryBucket: s3.IBucket; + public readonly jobsQueue: sqs.IQueue; + public readonly userPool: cognito.IUserPool; + + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // VPC: 2 AZs, public + private subnets, single NAT Gateway + this.vpc = new ec2.Vpc(this, 'Vpc', { + vpcName: 'proposal-system-vpc', + maxAzs: 2, + natGateways: 1, + subnetConfiguration: [ + { + name: 'public', + subnetType: ec2.SubnetType.PUBLIC, + cidrMask: 24, + }, + { + name: 'private', + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + cidrMask: 24, + }, + ], + }); + + // VPC Endpoints + this.vpc.addGatewayEndpoint('S3Endpoint', { + service: ec2.GatewayVpcEndpointAwsService.S3, + }); + + this.vpc.addInterfaceEndpoint('SecretsManagerEndpoint', { + service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, + }); + + // Security Groups + this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', { + vpc: this.vpc, + securityGroupName: 'proposal-system-lambda-sg', + description: 'Security group for proposal system Lambda functions', + allowAllOutbound: true, + }); + + const rdsSg = new ec2.SecurityGroup(this, 'RdsSg', { + vpc: this.vpc, + securityGroupName: 'proposal-system-rds-sg', + description: 'Security group for proposal system RDS instance', + allowAllOutbound: false, + }); + + rdsSg.addIngressRule( + this.lambdaSecurityGroup, + ec2.Port.tcp(5432), + 'Allow PostgreSQL from Lambda SG' + ); + + // RDS PostgreSQL 15 + const dbInstance = new rds.DatabaseInstance(this, 'Database', { + instanceIdentifier: 'proposal-system-db', + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_15, + }), + instanceType: ec2.InstanceType.of( + ec2.InstanceClass.T4G, + ec2.InstanceSize.SMALL + ), + vpc: this.vpc, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [rdsSg], + multiAz: false, + allocatedStorage: 20, + maxAllocatedStorage: 100, + storageEncrypted: true, + backupRetention: cdk.Duration.days(7), + deletionProtection: true, + removalPolicy: cdk.RemovalPolicy.RETAIN, + databaseName: 'proposals', + credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', { + secretName: 'proposal-system/db-credentials', + }), + publiclyAccessible: false, + }); + + this.dbSecret = dbInstance.secret!; + + // S3 Buckets + this.uploadsBucket = new s3.Bucket(this, 'UploadsBucket', { + bucketName: `proposal-system-uploads-${this.account}`, + encryption: s3.BucketEncryption.S3_MANAGED, + versioned: true, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + lifecycleRules: [ + { + transitions: [ + { + storageClass: s3.StorageClass.INFREQUENT_ACCESS, + transitionAfter: cdk.Duration.days(90), + }, + ], + }, + ], + cors: [ + { + allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST], + allowedOrigins: ['*'], + allowedHeaders: ['*'], + maxAge: 3600, + }, + ], + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + cdk.Tags.of(this.uploadsBucket).add('Purpose', 'Vendor PDFs and dispatcher attachments'); + cdk.Tags.of(this.uploadsBucket).add('ManagedBy', 'proposal-system'); + + this.generatedBucket = new s3.Bucket(this, 'GeneratedBucket', { + bucketName: `proposal-system-generated-${this.account}`, + encryption: s3.BucketEncryption.S3_MANAGED, + versioned: true, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + cdk.Tags.of(this.generatedBucket).add('Purpose', 'Generated proposal PDFs'); + cdk.Tags.of(this.generatedBucket).add('ManagedBy', 'proposal-system'); + + this.libraryBucket = new s3.Bucket(this, 'LibraryBucket', { + bucketName: `proposal-system-library-${this.account}`, + encryption: s3.BucketEncryption.S3_MANAGED, + versioned: true, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + cdk.Tags.of(this.libraryBucket).add('Purpose', 'Historical proposal library for RAG'); + cdk.Tags.of(this.libraryBucket).add('ManagedBy', 'proposal-system'); + + // SQS Queue + DLQ + const dlq = new sqs.Queue(this, 'JobsDlq', { + queueName: 'proposal-system-jobs-dlq', + retentionPeriod: cdk.Duration.days(14), + }); + + this.jobsQueue = new sqs.Queue(this, 'JobsQueue', { + queueName: 'proposal-system-jobs', + visibilityTimeout: cdk.Duration.seconds(180), + deadLetterQueue: { + queue: dlq, + maxReceiveCount: 3, + }, + }); + + // Cognito User Pool + const userPool = new cognito.UserPool(this, 'UserPool', { + userPoolName: 'proposal-system-auth', + selfSignUpEnabled: false, + signInAliases: { email: true }, + standardAttributes: { + email: { required: true, mutable: true }, + fullname: { required: true, mutable: true }, + }, + passwordPolicy: { + minLength: 12, + requireUppercase: true, + requireLowercase: true, + requireDigits: true, + requireSymbols: false, + }, + accountRecovery: cognito.AccountRecovery.EMAIL_ONLY, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + this.userPool = userPool; + + // Cognito Groups + new cognito.CfnUserPoolGroup(this, 'DispatchersGroup', { + userPoolId: userPool.userPoolId, + groupName: 'dispatchers', + description: 'Dispatchers who submit proposal requests', + }); + + new cognito.CfnUserPoolGroup(this, 'AdminsGroup', { + userPoolId: userPool.userPoolId, + groupName: 'admins', + description: 'Admins who review and approve proposals', + }); + + new cognito.CfnUserPoolGroup(this, 'SysadminsGroup', { + userPoolId: userPool.userPoolId, + groupName: 'sysadmins', + description: 'System administrators', + }); + + // Cognito Domain + userPool.addDomain('CognitoDomain', { + cognitoDomain: { domainPrefix: 'proposal-system-seahaven' }, + }); + + // Web App Client (PKCE) + userPool.addClient('WebClient', { + userPoolClientName: 'proposal-system-web', + generateSecret: false, + authFlows: { + userSrp: true, + }, + oAuth: { + flows: { authorizationCodeGrant: true }, + scopes: [ + cognito.OAuthScope.OPENID, + cognito.OAuthScope.EMAIL, + cognito.OAuthScope.PROFILE, + ], + callbackUrls: [ + 'https://proposals.seahaven.com/callback', + 'http://localhost:5173/callback', + ], + logoutUrls: [ + 'https://proposals.seahaven.com', + 'http://localhost:5173', + ], + }, + }); + + // Mobile App Client (PKCE) + userPool.addClient('MobileClient', { + userPoolClientName: 'proposal-system-mobile', + generateSecret: false, + authFlows: { + userSrp: true, + }, + oAuth: { + flows: { authorizationCodeGrant: true }, + scopes: [ + cognito.OAuthScope.OPENID, + cognito.OAuthScope.EMAIL, + cognito.OAuthScope.PROFILE, + ], + callbackUrls: ['proposalsystem://callback'], + logoutUrls: ['proposalsystem://logout'], + }, + }); + + // CloudWatch Log Groups + const logGroupNames = [ + 'proposal-system-api', + 'proposal-system-pdf-extract', + 'proposal-system-pdf-generate', + 'proposal-system-library-ingest', + ]; + + for (const name of logGroupNames) { + new logs.LogGroup(this, `LogGroup-${name}`, { + logGroupName: `/aws/lambda/${name}`, + retention: logs.RetentionDays.TWO_MONTHS, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + } + + // Outputs + new cdk.CfnOutput(this, 'VpcId', { value: this.vpc.vpcId }); + new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId }); + new cdk.CfnOutput(this, 'UserPoolArn', { value: userPool.userPoolArn }); + new cdk.CfnOutput(this, 'UploadsBucketName', { value: this.uploadsBucket.bucketName }); + new cdk.CfnOutput(this, 'GeneratedBucketName', { value: this.generatedBucket.bucketName }); + new cdk.CfnOutput(this, 'LibraryBucketName', { value: this.libraryBucket.bucketName }); + new cdk.CfnOutput(this, 'JobsQueueUrl', { value: this.jobsQueue.queueUrl }); + new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn }); + } +} diff --git a/infra/lib/frontend-stack.ts b/infra/lib/frontend-stack.ts new file mode 100644 index 0000000..dd3a247 --- /dev/null +++ b/infra/lib/frontend-stack.ts @@ -0,0 +1,49 @@ +import * as cdk from 'aws-cdk-lib'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; +import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins'; +import { Construct } from 'constructs'; + +export class FrontendStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const siteBucket = new s3.Bucket(this, 'SiteBucket', { + bucketName: `proposal-system-web-${this.account}`, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + + const distribution = new cloudfront.Distribution(this, 'Distribution', { + comment: 'proposal-system-web', + defaultBehavior: { + origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(siteBucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS, + }, + defaultRootObject: 'index.html', + errorResponses: [ + { + httpStatus: 403, + responseHttpStatus: 200, + responsePagePath: '/index.html', + ttl: cdk.Duration.seconds(0), + }, + { + httpStatus: 404, + responseHttpStatus: 200, + responsePagePath: '/index.html', + ttl: cdk.Duration.seconds(0), + }, + ], + minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021, + }); + + new cdk.CfnOutput(this, 'DistributionId', { value: distribution.distributionId }); + new cdk.CfnOutput(this, 'DistributionDomainName', { value: distribution.distributionDomainName }); + new cdk.CfnOutput(this, 'SiteBucketName', { value: siteBucket.bucketName }); + } +} diff --git a/infra/package-lock.json b/infra/package-lock.json new file mode 100644 index 0000000..4b4ec1a --- /dev/null +++ b/infra/package-lock.json @@ -0,0 +1,493 @@ +{ + "name": "proposal-system-infra", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proposal-system-infra", + "version": "1.0.0", + "dependencies": { + "aws-cdk-lib": "2.253.1", + "constructs": "^10.4.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "aws-cdk": "^2.1122.0", + "typescript": "~5.7.0" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.273", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.273.tgz", + "integrity": "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", + "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "53.24.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.24.0.tgz", + "integrity": "sha512-rCwsd0Y9z4CUmV5FhzjbO2MprXjKG0rxCACuLEY40lD+wInvgcHer0lSDo7Xq9Sxe/IoNe/Wxb2YP3GgxvT3GA==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.8.0" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.8.0", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aws-cdk": { + "version": "2.1122.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1122.0.tgz", + "integrity": "sha512-AI2Ks9qioWLvBPD4IoEtTet3wUG/o/q6U3WR3VCQKH5sNYLLPALo8o9sermNpMnfd1OQkqhL20tp4cEyojrIZg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.253.1", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.253.1.tgz", + "integrity": "sha512-vy+hA15/ZfSQpivkNdlIn2ZDA2hesp3WJgmtIZJDFwu6xzwv7wH7glbAdu5xCHGcOjepOaTKZSvCPC6sN+0/Vw==", + "bundleDependencies": [ + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.273", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", + "@aws-cdk/cloud-assembly-api": "^2.2.2", + "@aws-cdk/cloud-assembly-schema": "^53.18.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.3", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.3", + "punycode": "^2.3.1", + "semver": "^7.7.4", + "table": "^6.9.0", + "yaml": "1.10.3" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.2", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.4" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=53.15.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.18.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.3", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/constructs": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", + "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 0000000..b09d30a --- /dev/null +++ b/infra/package.json @@ -0,0 +1,21 @@ +{ + "name": "proposal-system-infra", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk", + "synth": "cdk synth", + "deploy": "cdk deploy --all --require-approval never" + }, + "dependencies": { + "aws-cdk-lib": "2.253.1", + "constructs": "^10.4.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "aws-cdk": "^2.1122.0", + "typescript": "~5.7.0" + } +} diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 0000000..bfe0f7d --- /dev/null +++ b/infra/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "outDir": "./dist", + "rootDir": "." + }, + "exclude": ["node_modules", "cdk.out", "dist"] +} diff --git a/lambdas/library-ingest/app.py b/lambdas/library-ingest/app.py new file mode 100644 index 0000000..6c4eaee --- /dev/null +++ b/lambdas/library-ingest/app.py @@ -0,0 +1,14 @@ +"""Proposal System - Library Ingest Lambda. + +Processes approved proposals into the Bedrock Knowledge Base. +Implementation in Phase 4. +""" + +import json + + +def handler(event, context): + for record in event.get("Records", []): + body = json.loads(record["body"]) + print(f"Processing library-ingest job: {body}") + return {"statusCode": 200} diff --git a/lambdas/library-ingest/requirements.txt b/lambdas/library-ingest/requirements.txt new file mode 100644 index 0000000..97c4921 --- /dev/null +++ b/lambdas/library-ingest/requirements.txt @@ -0,0 +1 @@ +boto3>=1.35.0,<2.0 diff --git a/lambdas/pdf-extract/app.py b/lambdas/pdf-extract/app.py new file mode 100644 index 0000000..42430aa --- /dev/null +++ b/lambdas/pdf-extract/app.py @@ -0,0 +1,14 @@ +"""Proposal System - PDF Extract Lambda. + +Parses vendor proposal PDFs and extracts structured line item data. +Implementation in Phase 4. +""" + +import json + + +def handler(event, context): + for record in event.get("Records", []): + body = json.loads(record["body"]) + print(f"Processing pdf-extract job: {body}") + return {"statusCode": 200} diff --git a/lambdas/pdf-extract/requirements.txt b/lambdas/pdf-extract/requirements.txt new file mode 100644 index 0000000..1d64c16 --- /dev/null +++ b/lambdas/pdf-extract/requirements.txt @@ -0,0 +1,2 @@ +pdfplumber>=0.11.0,<1.0 +boto3>=1.35.0,<2.0 diff --git a/lambdas/pdf-generate/app.py b/lambdas/pdf-generate/app.py new file mode 100644 index 0000000..b59ee6f --- /dev/null +++ b/lambdas/pdf-generate/app.py @@ -0,0 +1,14 @@ +"""Proposal System - PDF Generate Lambda. + +Generates professional branded proposal PDFs from approved proposals. +Implementation in Phase 5. +""" + +import json + + +def handler(event, context): + for record in event.get("Records", []): + body = json.loads(record["body"]) + print(f"Processing pdf-generate job: {body}") + return {"statusCode": 200} diff --git a/lambdas/pdf-generate/requirements.txt b/lambdas/pdf-generate/requirements.txt new file mode 100644 index 0000000..64cd117 --- /dev/null +++ b/lambdas/pdf-generate/requirements.txt @@ -0,0 +1,2 @@ +reportlab>=4.2.0,<5.0 +boto3>=1.35.0,<2.0 diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..2ba72eb --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,17 @@ +{ + "name": "proposal-system-mobile", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "react-native start", + "ios": "react-native run-ios" + }, + "dependencies": { + "react": "^19.0.0", + "react-native": "^0.76.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "typescript": "~5.7.0" + } +} diff --git a/shared/api-contracts/package.json b/shared/api-contracts/package.json new file mode 100644 index 0000000..8d0bbef --- /dev/null +++ b/shared/api-contracts/package.json @@ -0,0 +1,13 @@ +{ + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "~5.7.0" + } +} diff --git a/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts new file mode 100644 index 0000000..8d8a600 --- /dev/null +++ b/shared/api-contracts/src/index.ts @@ -0,0 +1,61 @@ +export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised'; +export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation'; +export type Priority = 'Standard' | 'Urgent' | 'Emergency'; +export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; +export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; +export type UserRole = 'Dispatcher' | 'Admin' | 'SysAdmin'; + +export interface Proposal { + id: string; + proposalNumber: string; + workOrderNumber: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + refinedScope: string | null; + serviceCategory: ServiceCategory; + priority: Priority; + status: ProposalStatus; + totalBidAmount: number; + vendorTotalCost: number | null; + notes: string; + submittedById: string; + submittedAt: string; + assignedAdminId: string | null; + approvedById: string | null; + approvedAt: string | null; + sentAt: string | null; + currentRevision: number; + parentProposalId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface LineItem { + id: string; + proposalId: string; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; + createdAt: string; + updatedAt: string; +} + +export interface CreateProposalRequest { + workOrderNumber: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + serviceCategory: ServiceCategory; + priority: Priority; + notes?: string; +} + +export interface UpdateLineItemsRequest { + lineItems: Omit[]; +} diff --git a/shared/api-contracts/tsconfig.json b/shared/api-contracts/tsconfig.json new file mode 100644 index 0000000..2c35d19 --- /dev/null +++ b/shared/api-contracts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7b7a1a9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Proposal System - Sea Haven Industries + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b1d81a9 --- /dev/null +++ b/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "proposal-system-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/material": "^7.0.0", + "@tanstack/react-query": "^5.60.0", + "axios": "^1.7.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "~5.7.0", + "vite": "^6.0.0" + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..7c13e11 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +function App() { + return
Proposal System
; +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1bc6e12 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..5c59447 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +});