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
This commit is contained in:
Adam Moussa 2026-05-16 18:40:46 -04:00
commit 0b055b3ad9
48 changed files with 2208 additions and 0 deletions

43
.github/dependabot.yml vendored Normal file
View file

@ -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

76
.github/workflows/ci.yaml vendored Normal file
View file

@ -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

89
.github/workflows/deploy.yaml vendored Normal file
View file

@ -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 "/*"

44
.gitignore vendored Normal file
View file

@ -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

100
README.md Normal file
View file

@ -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

37
api/ProposalSystem.sln Normal file
View file

@ -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

6
api/global.json Normal file
View file

@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.400",
"rollForward": "latestFeature"
}
}

View file

@ -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 });
}

View file

@ -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<ProposalDbContext>(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<ProposalDbContext>();
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/api/health");
app.Run();

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<AWSProjectType>Lambda</AWSProjectType>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ProposalSystem.Application\ProposalSystem.Application.csproj" />
<ProjectReference Include="..\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Amazon.Lambda.AspNetCoreServer.Hosting" Version="1.7.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"Authority": "",
"Audience": ""
},
"ConnectionStrings": {
"DefaultConnection": ""
}
}

View file

@ -0,0 +1,11 @@
using ProposalSystem.Domain.Entities;
namespace ProposalSystem.Application.Interfaces;
public interface IProposalRepository
{
Task<Proposal?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<Proposal>> GetAllAsync(CancellationToken ct = default);
Task<Proposal> CreateAsync(Proposal proposal, CancellationToken ct = default);
Task UpdateAsync(Proposal proposal, CancellationToken ct = default);
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ProposalSystem.Domain\ProposalSystem.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.11.0" />
</ItemGroup>
</Project>

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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<LineItem> LineItems { get; set; } = new List<LineItem>();
public ICollection<VendorProposal> VendorProposals { get; set; } = new List<VendorProposal>();
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using ProposalSystem.Domain.Entities;
namespace ProposalSystem.Infrastructure.Data;
public class ProposalDbContext : DbContext
{
public ProposalDbContext(DbContextOptions<ProposalDbContext> options) : base(options)
{
}
public DbSet<Proposal> Proposals => Set<Proposal>();
public DbSet<LineItem> LineItems => Set<LineItem>();
public DbSet<VendorProposal> VendorProposals => Set<VendorProposal>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<User> Users => Set<User>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<SimilarProposalReference> SimilarProposalReferences => Set<SimilarProposalReference>();
public DbSet<GeneratedPdf> GeneratedPdfs => Set<GeneratedPdf>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Proposal>(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<string>();
entity.Property(e => e.ServiceCategory).HasConversion<string>();
entity.Property(e => e.Priority).HasConversion<string>();
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<LineItem>(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<string>();
entity.Property(e => e.Source).HasConversion<string>();
entity.HasOne(e => e.Proposal).WithMany(p => p.LineItems).HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<VendorProposal>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.TotalVendorCost).HasPrecision(18, 2);
entity.Property(e => e.ProcessingStatus).HasConversion<string>();
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<Customer>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Addresses).HasColumnType("jsonb");
});
modelBuilder.Entity<User>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.CognitoSub).IsUnique();
entity.HasIndex(e => e.Email).IsUnique();
entity.Property(e => e.Role).HasConversion<string>();
});
modelBuilder.Entity<AuditLog>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Action).HasConversion<string>();
entity.Property(e => e.Details).HasColumnType("jsonb");
entity.HasIndex(e => e.ProposalId);
entity.HasIndex(e => e.Timestamp);
});
modelBuilder.Entity<SimilarProposalReference>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<GeneratedPdf>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade);
});
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ProposalSystem.Application\ProposalSystem.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageReference Include="AWSSDK.S3" Version="3.7.405.0" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.401.0" />
<PackageReference Include="AWSSDK.SecretsManager" Version="3.7.401.0" />
</ItemGroup>
</Project>

37
infra/bin/app.ts Normal file
View file

@ -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',
});

10
infra/cdk.context.json Normal file
View file

@ -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"
]
}

21
infra/cdk.json Normal file
View file

@ -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"]
}
}

199
infra/lib/compute-stack.ts Normal file
View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

493
infra/package-lock.json generated Normal file
View file

@ -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"
}
}
}

21
infra/package.json Normal file
View file

@ -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"
}
}

24
infra/tsconfig.json Normal file
View file

@ -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"]
}

View file

@ -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}

View file

@ -0,0 +1 @@
boto3>=1.35.0,<2.0

View file

@ -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}

View file

@ -0,0 +1,2 @@
pdfplumber>=0.11.0,<1.0
boto3>=1.35.0,<2.0

View file

@ -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}

View file

@ -0,0 +1,2 @@
reportlab>=4.2.0,<5.0
boto3>=1.35.0,<2.0

17
mobile/package.json Normal file
View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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<LineItem, 'id' | 'proposalId' | 'createdAt' | 'updatedAt'>[];
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

12
web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Proposal System - Sea Haven Industries</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
web/package.json Normal file
View file

@ -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"
}
}

12
web/src/main.tsx Normal file
View file

@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
function App() {
return <div>Proposal System</div>;
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

21
web/tsconfig.json Normal file
View file

@ -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"]
}

9
web/vite.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});