Rebuild platform with Node.js, Express.js and TypeScript
Complete rewrite from Python/FastAPI to Node.js stack: Features implemented: - User authentication with JWT tokens and role-based access (DEVELOPER/BUYER) - Blockchain wallet linking and management with Ethereum integration - Carbon project creation and management for developers - Marketplace for browsing and purchasing carbon offsets - Transaction tracking with blockchain integration - Comprehensive input validation with Joi - Advanced security with Helmet, CORS, and rate limiting - Error handling and logging middleware - Health check endpoint with service monitoring Technical stack: - Node.js with Express.js and TypeScript - Prisma ORM with SQLite database - Web3.js and Ethers.js for blockchain integration - JWT authentication with bcrypt password hashing - Comprehensive validation and security middleware - Production-ready error handling and logging Database schema: - Users with wallet linking capabilities - Carbon projects with verification status - Carbon offsets with blockchain token tracking - Transactions with confirmation details Environment variables required: - JWT_SECRET (required) - DATABASE_URL (optional, defaults to SQLite) - BLOCKCHAIN_RPC_URL (optional, defaults to localhost) - NODE_ENV, PORT, CORS_ORIGIN (optional) Run with: npm install && npm run db:generate && npm run db:migrate && npm run dev
This commit is contained in:
parent
e122f16dea
commit
3ef47ed096
18
.env.example
Normal file
18
.env.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Application Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=8000
|
||||||
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL="file:./storage/db/database.db"
|
||||||
|
|
||||||
|
# Blockchain Configuration
|
||||||
|
BLOCKCHAIN_RPC_URL=http://localhost:8545
|
||||||
|
ETHEREUM_NETWORK=localhost
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
28
.eslintrc.js
Normal file
28
.eslintrc.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
project: './tsconfig.json',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'@typescript-eslint/recommended',
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
es2022: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
ignorePatterns: ['dist/', 'node_modules/', '*.js'],
|
||||||
|
};
|
230
.gitignore
vendored
230
.gitignore
vendored
@ -1,195 +1,59 @@
|
|||||||
repos*
|
# Dependencies
|
||||||
# Byte-compiled / optimized / DLL files
|
node_modules/
|
||||||
__pycache__/
|
npm-debug.log*
|
||||||
*.py[cod]
|
yarn-debug.log*
|
||||||
*$py.class
|
yarn-error.log*
|
||||||
media/
|
|
||||||
*.db
|
|
||||||
whitelist.txt
|
|
||||||
ai_docs/
|
|
||||||
specs/
|
|
||||||
|
|
||||||
# C extensions
|
# Production builds
|
||||||
*.so
|
|
||||||
test_cases.py
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
build/
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
test_case1.py
|
|
||||||
api/core/dependencies/mailjet.py
|
|
||||||
tests/v1/waitlist/waitlist_test.py
|
|
||||||
result.json
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
# Environment variables
|
||||||
pip-log.txt
|
.env
|
||||||
test_case1.py
|
.env.local
|
||||||
pip-delete-this-directory.txt
|
.env.production
|
||||||
|
.env.test
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Database
|
||||||
htmlcov/
|
storage/
|
||||||
.tox/
|
*.db
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
case_test.py
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
*.sqlite3
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
# Flask stuff:
|
# Logs
|
||||||
instance/
|
logs/
|
||||||
.webassets-cache
|
*.log
|
||||||
|
|
||||||
# Scrapy stuff:
|
# Runtime data
|
||||||
.scrapy
|
pids/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
# Sphinx documentation
|
# Coverage directory used by tools like istanbul
|
||||||
docs/_build/
|
coverage/
|
||||||
|
|
||||||
# PyBuilder
|
# IDE files
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
||||||
.pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env*
|
|
||||||
!.env.sample
|
|
||||||
.venv
|
|
||||||
.blog_env/
|
|
||||||
env/
|
|
||||||
venv*
|
|
||||||
*venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
|
|
||||||
# Spyder project settings
|
|
||||||
.spyderproject
|
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
|
||||||
.ropeproject
|
|
||||||
|
|
||||||
# mkdocs documentation
|
|
||||||
/site
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
.vscode/
|
.vscode/
|
||||||
jeff.py
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|
||||||
**/.DS_Store
|
|
||||||
.aider*
|
|
||||||
|
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
.dump.rdb
|
*.swp
|
||||||
.celery.log
|
*.swo
|
||||||
docker-compose.yaml
|
*~
|
||||||
# project analysis result
|
|
||||||
analysis_results.json
|
|
||||||
|
|
||||||
**/.claude/settings.local.json
|
# OS generated files
|
||||||
*.aider
|
.DS_Store
|
||||||
.claude/
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/migrations/
|
198
README.md
198
README.md
@ -26,110 +26,119 @@ A blockchain-enabled carbon offset trading platform that connects project develo
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: FastAPI (Python)
|
- **Backend**: Node.js with Express.js and TypeScript
|
||||||
- **Database**: SQLite with SQLAlchemy ORM
|
- **Database**: SQLite with Prisma ORM
|
||||||
- **Authentication**: JWT tokens with bcrypt password hashing
|
- **Authentication**: JWT tokens with bcrypt password hashing
|
||||||
- **Blockchain**: Web3.py for Ethereum integration
|
- **Blockchain**: Web3.js and Ethers.js for Ethereum integration
|
||||||
- **Database Migrations**: Alembic
|
- **Validation**: Joi for request validation
|
||||||
- **API Documentation**: OpenAPI/Swagger
|
- **Security**: Helmet, CORS, Rate limiting
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── main.py # FastAPI application entry point
|
├── src/
|
||||||
├── requirements.txt # Python dependencies
|
│ ├── server.ts # Express application entry point
|
||||||
├── alembic.ini # Database migration configuration
|
│ ├── types/ # TypeScript type definitions
|
||||||
├── openapi.json # API specification
|
│ │ └── index.ts # Main types and interfaces
|
||||||
├── app/
|
│ ├── routes/ # API endpoints
|
||||||
│ ├── api/ # API endpoints
|
│ │ ├── auth.ts # Authentication endpoints
|
||||||
│ │ ├── auth.py # Authentication endpoints
|
│ │ ├── wallet.ts # Wallet management endpoints
|
||||||
│ │ ├── wallet.py # Wallet management endpoints
|
│ │ ├── projects.ts # Project management endpoints
|
||||||
│ │ ├── projects.py # Project management endpoints
|
│ │ └── trading.ts # Trading and marketplace endpoints
|
||||||
│ │ └── trading.py # Trading and marketplace endpoints
|
│ ├── middleware/ # Express middleware
|
||||||
│ ├── core/ # Core functionality
|
│ │ ├── auth.ts # Authentication middleware
|
||||||
│ │ ├── security.py # Authentication and security
|
│ │ ├── validation.ts # Request validation middleware
|
||||||
│ │ └── deps.py # Dependency injection
|
│ │ ├── security.ts # Security middleware
|
||||||
│ ├── db/ # Database configuration
|
│ │ └── error.ts # Error handling middleware
|
||||||
│ │ ├── base.py # SQLAlchemy base
|
│ ├── services/ # Business logic services
|
||||||
│ │ └── session.py # Database session management
|
│ │ ├── blockchain.ts # Blockchain integration
|
||||||
│ ├── models/ # Database models
|
│ │ └── wallet.ts # Wallet management
|
||||||
│ │ ├── user.py # User model
|
│ └── utils/ # Utility functions
|
||||||
│ │ ├── carbon_project.py # Carbon project model
|
│ ├── database.ts # Database connection and utilities
|
||||||
│ │ ├── carbon_offset.py # Carbon offset model
|
│ └── auth.ts # Authentication utilities
|
||||||
│ │ └── transaction.py # Transaction model
|
├── prisma/
|
||||||
│ ├── schemas/ # Pydantic schemas
|
│ └── schema.prisma # Database schema
|
||||||
│ │ ├── user.py # User schemas
|
├── storage/ # Application storage directory
|
||||||
│ │ ├── carbon_project.py # Project schemas
|
│ └── db/ # SQLite database files
|
||||||
│ │ └── transaction.py # Transaction schemas
|
├── package.json # Node.js dependencies and scripts
|
||||||
│ └── services/ # Business logic services
|
├── tsconfig.json # TypeScript configuration
|
||||||
│ ├── blockchain.py # Blockchain integration
|
└── .env.example # Environment variables template
|
||||||
│ └── wallet.py # Wallet management
|
|
||||||
└── alembic/ # Database migrations
|
|
||||||
└── versions/ # Migration files
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run database migrations:
|
2. Set up environment variables:
|
||||||
```bash
|
```bash
|
||||||
alembic upgrade head
|
cp .env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set required environment variables:
|
3. Generate Prisma client and run migrations:
|
||||||
```bash
|
```bash
|
||||||
export SECRET_KEY="your-secret-key-here"
|
npm run db:generate
|
||||||
export BLOCKCHAIN_RPC_URL="https://your-ethereum-rpc-url" # Optional, defaults to localhost
|
npm run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running the Application
|
## Running the Application
|
||||||
|
|
||||||
Start the development server:
|
### Development
|
||||||
|
Start the development server with hot reload:
|
||||||
```bash
|
```bash
|
||||||
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
Build and start the production server:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will be available at:
|
The application will be available at:
|
||||||
- **API**: http://localhost:8000
|
- **API**: http://localhost:8000
|
||||||
- **Documentation**: http://localhost:8000/docs
|
|
||||||
- **Alternative Docs**: http://localhost:8000/redoc
|
|
||||||
- **Health Check**: http://localhost:8000/health
|
- **Health Check**: http://localhost:8000/health
|
||||||
|
- **API Documentation**: http://localhost:8000/api/docs
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
| Variable | Description | Required | Default |
|
||||||
|----------|-------------|----------|---------|
|
|----------|-------------|----------|---------|
|
||||||
| `SECRET_KEY` | JWT token secret key | Yes | - |
|
| `NODE_ENV` | Environment mode | No | development |
|
||||||
|
| `PORT` | Server port | No | 8000 |
|
||||||
|
| `JWT_SECRET` | JWT token secret | Yes | - |
|
||||||
|
| `DATABASE_URL` | SQLite database file path | No | file:./storage/db/database.db |
|
||||||
| `BLOCKCHAIN_RPC_URL` | Ethereum RPC endpoint | No | http://localhost:8545 |
|
| `BLOCKCHAIN_RPC_URL` | Ethereum RPC endpoint | No | http://localhost:8545 |
|
||||||
|
| `CORS_ORIGIN` | CORS allowed origins | No | * |
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
- `POST /auth/register` - Register new user (developer or buyer)
|
- `POST /api/auth/register` - Register new user (developer or buyer)
|
||||||
- `POST /auth/login` - User login
|
- `POST /api/auth/login` - User login
|
||||||
|
|
||||||
### Wallet Management
|
### Wallet Management
|
||||||
- `POST /wallet/link` - Link blockchain wallet to user account
|
- `POST /api/wallet/link` - Link blockchain wallet to user account
|
||||||
- `DELETE /wallet/unlink` - Unlink wallet from user account
|
- `DELETE /api/wallet/unlink` - Unlink wallet from user account
|
||||||
- `GET /wallet/info` - Get wallet information and balance
|
- `GET /api/wallet/info` - Get wallet information and balance
|
||||||
- `POST /wallet/generate-test-wallet` - Generate test wallet for development
|
- `POST /api/wallet/generate-test-wallet` - Generate test wallet for development
|
||||||
|
|
||||||
### Project Management (Developers)
|
### Project Management (Developers)
|
||||||
- `POST /projects/` - Create new carbon offset project
|
- `POST /api/projects/` - Create new carbon offset project
|
||||||
- `GET /projects/my-projects` - Get developer's projects
|
- `GET /api/projects/my-projects` - Get developer's projects
|
||||||
- `PUT /projects/{project_id}` - Update project
|
- `PUT /api/projects/{project_id}` - Update project
|
||||||
- `DELETE /projects/{project_id}` - Delete project
|
- `DELETE /api/projects/{project_id}` - Delete project
|
||||||
|
|
||||||
### Marketplace & Trading
|
### Marketplace & Trading
|
||||||
- `GET /projects/` - Browse all available projects
|
- `GET /api/projects/` - Browse all available projects
|
||||||
- `GET /projects/{project_id}` - Get project details
|
- `GET /api/projects/{project_id}` - Get project details
|
||||||
- `POST /trading/purchase` - Purchase carbon offsets
|
- `POST /api/trading/purchase` - Purchase carbon offsets
|
||||||
- `GET /trading/my-transactions` - Get user's transactions
|
- `GET /api/trading/my-transactions` - Get user's transactions
|
||||||
- `GET /trading/marketplace` - Get marketplace statistics
|
- `GET /api/trading/marketplace/stats` - Get marketplace statistics
|
||||||
|
|
||||||
### System
|
### System
|
||||||
- `GET /` - Platform information
|
- `GET /` - Platform information
|
||||||
@ -140,7 +149,7 @@ The application will be available at:
|
|||||||
### Users
|
### Users
|
||||||
- User authentication and profile information
|
- User authentication and profile information
|
||||||
- Wallet linking for blockchain integration
|
- Wallet linking for blockchain integration
|
||||||
- User types: "developer" or "buyer"
|
- User types: DEVELOPER or BUYER
|
||||||
|
|
||||||
### Carbon Projects
|
### Carbon Projects
|
||||||
- Project details and metadata
|
- Project details and metadata
|
||||||
@ -163,39 +172,76 @@ The application will be available at:
|
|||||||
|
|
||||||
### Database Migrations
|
### Database Migrations
|
||||||
|
|
||||||
Create a new migration:
|
Generate Prisma client:
|
||||||
```bash
|
```bash
|
||||||
alembic revision --autogenerate -m "Description of changes"
|
npm run db:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
Apply migrations:
|
Create and apply migrations:
|
||||||
```bash
|
```bash
|
||||||
alembic upgrade head
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
View database in Prisma Studio:
|
||||||
|
```bash
|
||||||
|
npm run db:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
Run linting:
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix linting issues:
|
||||||
|
```bash
|
||||||
|
npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
Build TypeScript:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing Wallet Integration
|
### Testing Wallet Integration
|
||||||
|
|
||||||
Use the test wallet generation endpoint to create wallets for development:
|
Use the test wallet generation endpoint to create wallets for development:
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/wallet/generate-test-wallet
|
curl -X POST http://localhost:8000/api/wallet/generate-test-wallet
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
- JWT-based authentication
|
- JWT-based authentication with secure token generation
|
||||||
- Password hashing with bcrypt
|
- Password hashing with bcrypt (12 rounds)
|
||||||
- Role-based access control (developer/buyer)
|
- Role-based access control (DEVELOPER/BUYER)
|
||||||
|
- Rate limiting and CORS protection
|
||||||
|
- Helmet security headers
|
||||||
|
- Input validation with Joi
|
||||||
- Blockchain wallet verification
|
- Blockchain wallet verification
|
||||||
- Transaction signing and verification
|
- Transaction signing and verification
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
1. Set production environment variables in `.env`
|
||||||
|
2. Build the application: `npm run build`
|
||||||
|
3. Start the production server: `npm start`
|
||||||
|
4. Ensure database is properly migrated
|
||||||
|
5. Configure reverse proxy (nginx/Apache)
|
||||||
|
6. Set up SSL certificates
|
||||||
|
7. Configure firewall and security groups
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Follow the existing code structure and patterns
|
1. Follow TypeScript best practices
|
||||||
2. Use type hints for all functions and methods
|
2. Use the existing code structure and patterns
|
||||||
3. Add appropriate error handling and validation
|
3. Add comprehensive input validation
|
||||||
4. Update documentation for any API changes
|
4. Include proper error handling
|
||||||
5. Test wallet integration thoroughly
|
5. Update documentation for API changes
|
||||||
|
6. Test blockchain integration thoroughly
|
||||||
|
7. Follow the established commit message format
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is part of a carbon offset trading platform implementation.
|
This project is part of a carbon offset trading platform implementation.
|
97
alembic.ini
97
alembic.ini
@ -1,97 +0,0 @@
|
|||||||
# A generic, single database configuration.
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
# path to migration scripts
|
|
||||||
script_location = alembic
|
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
|
||||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
||||||
|
|
||||||
# sys.path path, will be prepended to sys.path if present.
|
|
||||||
# defaults to the current working directory.
|
|
||||||
prepend_sys_path = .
|
|
||||||
|
|
||||||
# timezone to use when rendering the date within the migration file
|
|
||||||
# as well as the filename.
|
|
||||||
# If specified, requires the python-dateutil library that can be
|
|
||||||
# installed by adding `alembic[tz]` to the pip requirements
|
|
||||||
# string value is passed to dateutil.tz.gettz()
|
|
||||||
# leave blank for localtime
|
|
||||||
# timezone =
|
|
||||||
|
|
||||||
# max length of characters to apply to the
|
|
||||||
# "slug" field
|
|
||||||
# max_length = 40
|
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
|
||||||
# the 'revision' command, regardless of autogenerate
|
|
||||||
# revision_environment = false
|
|
||||||
|
|
||||||
# set to 'true' to allow .pyc and .pyo files without
|
|
||||||
# a source .py file to be detected as revisions in the
|
|
||||||
# versions/ directory
|
|
||||||
# sourceless = false
|
|
||||||
|
|
||||||
# version path separator; As mentioned above, this is the character used to split
|
|
||||||
# version_locations. The default within new alembic.ini files is "os", which uses
|
|
||||||
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
|
|
||||||
# behavior of splitting on spaces and/or commas.
|
|
||||||
# Valid values for version_path_separator are:
|
|
||||||
#
|
|
||||||
# version_path_separator = :
|
|
||||||
# version_path_separator = ;
|
|
||||||
# version_path_separator = space
|
|
||||||
version_path_separator = os
|
|
||||||
|
|
||||||
# the output encoding used when revision files
|
|
||||||
# are written from script.py.mako
|
|
||||||
# output_encoding = utf-8
|
|
||||||
|
|
||||||
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
|
|
||||||
|
|
||||||
[post_write_hooks]
|
|
||||||
# post_write_hooks defines scripts or Python functions that are run
|
|
||||||
# on newly generated revision scripts. See the documentation for further
|
|
||||||
# detail and examples
|
|
||||||
|
|
||||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
|
||||||
# hooks = black
|
|
||||||
# black.type = console_scripts
|
|
||||||
# black.entrypoint = black
|
|
||||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
@ -1,87 +0,0 @@
|
|||||||
from logging.config import fileConfig
|
|
||||||
from sqlalchemy import engine_from_config
|
|
||||||
from sqlalchemy import pool
|
|
||||||
from alembic import context
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add the project root to Python path
|
|
||||||
sys.path.append(str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
# Import models
|
|
||||||
from app.db.base import Base
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.carbon_project import CarbonProject
|
|
||||||
from app.models.carbon_offset import CarbonOffset
|
|
||||||
from app.models.transaction import Transaction
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
|
||||||
# can be acquired:
|
|
||||||
# my_important_option = config.get_main_option("my_important_option")
|
|
||||||
# ... etc.
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
"""Run migrations in 'offline' mode.
|
|
||||||
|
|
||||||
This configures the context with just a URL
|
|
||||||
and not an Engine, though an Engine is acceptable
|
|
||||||
here as well. By skipping the Engine creation
|
|
||||||
we don't even need a DBAPI to be available.
|
|
||||||
|
|
||||||
Calls to context.execute() here emit the given string to the
|
|
||||||
script output.
|
|
||||||
|
|
||||||
"""
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={"paramstyle": "named"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
"""Run migrations in 'online' mode.
|
|
||||||
|
|
||||||
In this scenario we need to create an Engine
|
|
||||||
and associate a connection with the context.
|
|
||||||
|
|
||||||
"""
|
|
||||||
connectable = engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(
|
|
||||||
connection=connection, target_metadata=target_metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
@ -1,24 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = ${repr(up_revision)}
|
|
||||||
down_revision = ${repr(down_revision)}
|
|
||||||
branch_labels = ${repr(branch_labels)}
|
|
||||||
depends_on = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
@ -1,118 +0,0 @@
|
|||||||
"""Initial migration
|
|
||||||
|
|
||||||
Revision ID: 001
|
|
||||||
Revises:
|
|
||||||
Create Date: 2024-01-01 12:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '001'
|
|
||||||
down_revision = None
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Create users table
|
|
||||||
op.create_table('users',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('email', sa.String(), nullable=False),
|
|
||||||
sa.Column('hashed_password', sa.String(), nullable=False),
|
|
||||||
sa.Column('full_name', sa.String(), nullable=False),
|
|
||||||
sa.Column('user_type', sa.String(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('wallet_address', sa.String(), nullable=True),
|
|
||||||
sa.Column('wallet_public_key', sa.Text(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
|
||||||
op.create_index(op.f('ix_users_wallet_address'), 'users', ['wallet_address'], unique=True)
|
|
||||||
|
|
||||||
# Create carbon_projects table
|
|
||||||
op.create_table('carbon_projects',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('title', sa.String(), nullable=False),
|
|
||||||
sa.Column('description', sa.Text(), nullable=False),
|
|
||||||
sa.Column('location', sa.String(), nullable=False),
|
|
||||||
sa.Column('project_type', sa.String(), nullable=False),
|
|
||||||
sa.Column('methodology', sa.String(), nullable=False),
|
|
||||||
sa.Column('total_credits_available', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('credits_sold', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('price_per_credit', sa.Float(), nullable=False),
|
|
||||||
sa.Column('start_date', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('end_date', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('verification_status', sa.String(), nullable=True),
|
|
||||||
sa.Column('verification_document_url', sa.String(), nullable=True),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('contract_address', sa.String(), nullable=True),
|
|
||||||
sa.Column('token_id', sa.String(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('developer_id', sa.Integer(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['developer_id'], ['users.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_carbon_projects_id'), 'carbon_projects', ['id'], unique=False)
|
|
||||||
|
|
||||||
# Create carbon_offsets table
|
|
||||||
op.create_table('carbon_offsets',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('serial_number', sa.String(), nullable=False),
|
|
||||||
sa.Column('vintage_year', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('status', sa.String(), nullable=True),
|
|
||||||
sa.Column('token_id', sa.String(), nullable=True),
|
|
||||||
sa.Column('blockchain_hash', sa.String(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['project_id'], ['carbon_projects.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_carbon_offsets_id'), 'carbon_offsets', ['id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_carbon_offsets_serial_number'), 'carbon_offsets', ['serial_number'], unique=True)
|
|
||||||
op.create_index(op.f('ix_carbon_offsets_token_id'), 'carbon_offsets', ['token_id'], unique=True)
|
|
||||||
|
|
||||||
# Create transactions table
|
|
||||||
op.create_table('transactions',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('transaction_hash', sa.String(), nullable=False),
|
|
||||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('price_per_credit', sa.Float(), nullable=False),
|
|
||||||
sa.Column('total_amount', sa.Float(), nullable=False),
|
|
||||||
sa.Column('status', sa.String(), nullable=True),
|
|
||||||
sa.Column('block_number', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('gas_used', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('buyer_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('offset_id', sa.Integer(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['buyer_id'], ['users.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['offset_id'], ['carbon_offsets.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_transactions_id'), 'transactions', ['id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_transactions_transaction_hash'), 'transactions', ['transaction_hash'], unique=True)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index(op.f('ix_transactions_transaction_hash'), table_name='transactions')
|
|
||||||
op.drop_index(op.f('ix_transactions_id'), table_name='transactions')
|
|
||||||
op.drop_table('transactions')
|
|
||||||
op.drop_index(op.f('ix_carbon_offsets_token_id'), table_name='carbon_offsets')
|
|
||||||
op.drop_index(op.f('ix_carbon_offsets_serial_number'), table_name='carbon_offsets')
|
|
||||||
op.drop_index(op.f('ix_carbon_offsets_id'), table_name='carbon_offsets')
|
|
||||||
op.drop_table('carbon_offsets')
|
|
||||||
op.drop_index(op.f('ix_carbon_projects_id'), table_name='carbon_projects')
|
|
||||||
op.drop_table('carbon_projects')
|
|
||||||
op.drop_index(op.f('ix_users_wallet_address'), table_name='users')
|
|
||||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
|
||||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
|
||||||
op.drop_table('users')
|
|
@ -1 +0,0 @@
|
|||||||
# Carbon Offset Trading Platform
|
|
@ -1 +0,0 @@
|
|||||||
# API modules
|
|
@ -1,60 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
|
|
||||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserResponse)
|
|
||||||
def register(user: UserCreate, db: Session = Depends(get_db)):
|
|
||||||
# Check if user already exists
|
|
||||||
existing_user = db.query(User).filter(User.email == user.email).first()
|
|
||||||
if existing_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Email already registered"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate user type
|
|
||||||
if user.user_type not in ["developer", "buyer"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Invalid user type. Must be 'developer' or 'buyer'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new user
|
|
||||||
hashed_password = get_password_hash(user.password)
|
|
||||||
db_user = User(
|
|
||||||
email=user.email,
|
|
||||||
hashed_password=hashed_password,
|
|
||||||
full_name=user.full_name,
|
|
||||||
user_type=user.user_type
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(db_user)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_user)
|
|
||||||
|
|
||||||
return db_user
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
|
||||||
def login(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
|
||||||
user = db.query(User).filter(User.email == user_credentials.email).first()
|
|
||||||
|
|
||||||
if not user or not verify_password(user_credentials.password, user.hashed_password):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Incorrect email or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Inactive user"
|
|
||||||
)
|
|
||||||
|
|
||||||
access_token = create_access_token(subject=user.id)
|
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
|
@ -1,175 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import desc
|
|
||||||
from typing import Optional
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.core.deps import get_current_user, get_current_developer
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.carbon_project import CarbonProject
|
|
||||||
from app.models.carbon_offset import CarbonOffset
|
|
||||||
from app.schemas.carbon_project import (
|
|
||||||
CarbonProjectCreate,
|
|
||||||
CarbonProjectResponse,
|
|
||||||
CarbonProjectUpdate,
|
|
||||||
CarbonProjectListResponse
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/", response_model=CarbonProjectResponse)
|
|
||||||
def create_project(
|
|
||||||
project: CarbonProjectCreate,
|
|
||||||
current_user: User = Depends(get_current_developer),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Create a new carbon offset project (Developer only)"""
|
|
||||||
|
|
||||||
# Validate project dates
|
|
||||||
if project.start_date >= project.end_date:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Start date must be before end date"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create project
|
|
||||||
db_project = CarbonProject(
|
|
||||||
**project.dict(),
|
|
||||||
developer_id=current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(db_project)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_project)
|
|
||||||
|
|
||||||
# Create initial carbon offsets
|
|
||||||
db_offset = CarbonOffset(
|
|
||||||
serial_number=f"CO{db_project.id}-{project.total_credits_available}",
|
|
||||||
vintage_year=project.start_date.year,
|
|
||||||
quantity=project.total_credits_available,
|
|
||||||
project_id=db_project.id
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(db_offset)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return db_project
|
|
||||||
|
|
||||||
@router.get("/", response_model=CarbonProjectListResponse)
|
|
||||||
def list_projects(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
|
||||||
project_type: Optional[str] = None,
|
|
||||||
verification_status: Optional[str] = None,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""List all active carbon offset projects"""
|
|
||||||
|
|
||||||
query = db.query(CarbonProject).filter(CarbonProject.is_active == True)
|
|
||||||
|
|
||||||
if project_type:
|
|
||||||
query = query.filter(CarbonProject.project_type == project_type)
|
|
||||||
|
|
||||||
if verification_status:
|
|
||||||
query = query.filter(CarbonProject.verification_status == verification_status)
|
|
||||||
|
|
||||||
# Get total count
|
|
||||||
total = query.count()
|
|
||||||
|
|
||||||
# Apply pagination
|
|
||||||
projects = query.order_by(desc(CarbonProject.created_at)).offset(
|
|
||||||
(page - 1) * page_size
|
|
||||||
).limit(page_size).all()
|
|
||||||
|
|
||||||
return CarbonProjectListResponse(
|
|
||||||
projects=projects,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/my-projects", response_model=CarbonProjectListResponse)
|
|
||||||
def get_my_projects(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_developer),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get projects created by the current developer"""
|
|
||||||
|
|
||||||
query = db.query(CarbonProject).filter(CarbonProject.developer_id == current_user.id)
|
|
||||||
|
|
||||||
total = query.count()
|
|
||||||
|
|
||||||
projects = query.order_by(desc(CarbonProject.created_at)).offset(
|
|
||||||
(page - 1) * page_size
|
|
||||||
).limit(page_size).all()
|
|
||||||
|
|
||||||
return CarbonProjectListResponse(
|
|
||||||
projects=projects,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=CarbonProjectResponse)
|
|
||||||
def get_project(project_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""Get a specific project by ID"""
|
|
||||||
|
|
||||||
project = db.query(CarbonProject).filter(
|
|
||||||
CarbonProject.id == project_id,
|
|
||||||
CarbonProject.is_active == True
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
return project
|
|
||||||
|
|
||||||
@router.put("/{project_id}", response_model=CarbonProjectResponse)
|
|
||||||
def update_project(
|
|
||||||
project_id: int,
|
|
||||||
project_update: CarbonProjectUpdate,
|
|
||||||
current_user: User = Depends(get_current_developer),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Update a project (Developer only - own projects)"""
|
|
||||||
|
|
||||||
project = db.query(CarbonProject).filter(
|
|
||||||
CarbonProject.id == project_id,
|
|
||||||
CarbonProject.developer_id == current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
# Update project fields
|
|
||||||
update_data = project_update.dict(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(project, field, value)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(project)
|
|
||||||
|
|
||||||
return project
|
|
||||||
|
|
||||||
@router.delete("/{project_id}")
|
|
||||||
def delete_project(
|
|
||||||
project_id: int,
|
|
||||||
current_user: User = Depends(get_current_developer),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Delete a project (Developer only - own projects)"""
|
|
||||||
|
|
||||||
project = db.query(CarbonProject).filter(
|
|
||||||
CarbonProject.id == project_id,
|
|
||||||
CarbonProject.developer_id == current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
# Soft delete - mark as inactive
|
|
||||||
project.is_active = False
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"message": "Project deleted successfully"}
|
|
@ -1,215 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import desc, func
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.core.deps import get_current_user, get_current_buyer
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.carbon_project import CarbonProject
|
|
||||||
from app.models.carbon_offset import CarbonOffset
|
|
||||||
from app.models.transaction import Transaction
|
|
||||||
from app.schemas.transaction import (
|
|
||||||
PurchaseRequest,
|
|
||||||
TransactionResponse,
|
|
||||||
TransactionListResponse
|
|
||||||
)
|
|
||||||
from app.services.blockchain import blockchain_service
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/purchase", response_model=TransactionResponse)
|
|
||||||
def purchase_carbon_offsets(
|
|
||||||
purchase: PurchaseRequest,
|
|
||||||
current_user: User = Depends(get_current_buyer),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Purchase carbon offsets from a project (Buyer only)"""
|
|
||||||
|
|
||||||
# Check if user has wallet linked
|
|
||||||
if not current_user.wallet_address:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Wallet must be linked to purchase carbon offsets"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get project
|
|
||||||
project = db.query(CarbonProject).filter(
|
|
||||||
CarbonProject.id == purchase.project_id,
|
|
||||||
CarbonProject.is_active == True,
|
|
||||||
CarbonProject.verification_status == "verified"
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Project not found or not verified"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if enough credits are available
|
|
||||||
available_credits = project.total_credits_available - project.credits_sold
|
|
||||||
if purchase.quantity > available_credits:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Not enough credits available. Available: {available_credits}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get available offset
|
|
||||||
offset = db.query(CarbonOffset).filter(
|
|
||||||
CarbonOffset.project_id == purchase.project_id,
|
|
||||||
CarbonOffset.status == "available"
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not offset:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="No available carbon offsets for this project"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate total amount
|
|
||||||
total_amount = purchase.quantity * project.price_per_credit
|
|
||||||
|
|
||||||
# Create transaction record
|
|
||||||
transaction_hash = f"tx_{uuid.uuid4().hex[:16]}"
|
|
||||||
|
|
||||||
db_transaction = Transaction(
|
|
||||||
transaction_hash=transaction_hash,
|
|
||||||
quantity=purchase.quantity,
|
|
||||||
price_per_credit=project.price_per_credit,
|
|
||||||
total_amount=total_amount,
|
|
||||||
buyer_id=current_user.id,
|
|
||||||
offset_id=offset.id,
|
|
||||||
status="pending"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(db_transaction)
|
|
||||||
|
|
||||||
# Update project credits sold
|
|
||||||
project.credits_sold += purchase.quantity
|
|
||||||
|
|
||||||
# Update offset quantity or status
|
|
||||||
if offset.quantity <= purchase.quantity:
|
|
||||||
offset.status = "sold"
|
|
||||||
else:
|
|
||||||
offset.quantity -= purchase.quantity
|
|
||||||
# Create new offset for remaining quantity
|
|
||||||
new_offset = CarbonOffset(
|
|
||||||
serial_number=f"CO{project.id}-{offset.quantity}",
|
|
||||||
vintage_year=offset.vintage_year,
|
|
||||||
quantity=purchase.quantity,
|
|
||||||
status="sold",
|
|
||||||
project_id=project.id
|
|
||||||
)
|
|
||||||
db.add(new_offset)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_transaction)
|
|
||||||
|
|
||||||
# In a real implementation, you would integrate with actual blockchain here
|
|
||||||
# For now, we'll simulate transaction confirmation
|
|
||||||
db_transaction.status = "confirmed"
|
|
||||||
db_transaction.confirmed_at = datetime.utcnow()
|
|
||||||
db_transaction.block_number = 12345678 # Simulated block number
|
|
||||||
db_transaction.gas_used = 21000 # Simulated gas usage
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_transaction)
|
|
||||||
|
|
||||||
return db_transaction
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Transaction failed: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/my-transactions", response_model=TransactionListResponse)
|
|
||||||
def get_my_transactions(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
|
||||||
status: Optional[str] = None,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get current user's transactions"""
|
|
||||||
|
|
||||||
query = db.query(Transaction).filter(Transaction.buyer_id == current_user.id)
|
|
||||||
|
|
||||||
if status:
|
|
||||||
query = query.filter(Transaction.status == status)
|
|
||||||
|
|
||||||
total = query.count()
|
|
||||||
|
|
||||||
transactions = query.order_by(desc(Transaction.created_at)).offset(
|
|
||||||
(page - 1) * page_size
|
|
||||||
).limit(page_size).all()
|
|
||||||
|
|
||||||
return TransactionListResponse(
|
|
||||||
transactions=transactions,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/transactions/{transaction_id}", response_model=TransactionResponse)
|
|
||||||
def get_transaction(
|
|
||||||
transaction_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get a specific transaction"""
|
|
||||||
|
|
||||||
transaction = db.query(Transaction).filter(
|
|
||||||
Transaction.id == transaction_id,
|
|
||||||
Transaction.buyer_id == current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
|
||||||
|
|
||||||
return transaction
|
|
||||||
|
|
||||||
@router.get("/marketplace", response_model=dict)
|
|
||||||
def get_marketplace_stats(db: Session = Depends(get_db)):
|
|
||||||
"""Get marketplace statistics"""
|
|
||||||
|
|
||||||
# Total active projects
|
|
||||||
total_projects = db.query(CarbonProject).filter(
|
|
||||||
CarbonProject.is_active == True
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Total verified projects
|
|
||||||
verified_projects = db.query(CarbonProject).filter(
|
|
||||||
CarbonProject.is_active == True,
|
|
||||||
CarbonProject.verification_status == "verified"
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Total credits available
|
|
||||||
total_credits = db.query(CarbonProject).filter(
|
|
||||||
CarbonProject.is_active == True
|
|
||||||
).with_entities(
|
|
||||||
func.sum(CarbonProject.total_credits_available - CarbonProject.credits_sold)
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
# Total transactions
|
|
||||||
total_transactions = db.query(Transaction).filter(
|
|
||||||
Transaction.status == "confirmed"
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Total volume traded
|
|
||||||
total_volume = db.query(Transaction).filter(
|
|
||||||
Transaction.status == "confirmed"
|
|
||||||
).with_entities(
|
|
||||||
func.sum(Transaction.total_amount)
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_projects": total_projects,
|
|
||||||
"verified_projects": verified_projects,
|
|
||||||
"total_credits_available": total_credits,
|
|
||||||
"total_transactions": total_transactions,
|
|
||||||
"total_volume_traded": total_volume
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.user import WalletLinkRequest, WalletResponse
|
|
||||||
from app.services.wallet import wallet_service
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/link", response_model=WalletResponse)
|
|
||||||
def link_wallet(
|
|
||||||
wallet_request: WalletLinkRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
result = wallet_service.link_wallet(
|
|
||||||
db=db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
wallet_address=wallet_request.wallet_address
|
|
||||||
)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
raise HTTPException(status_code=400, detail=result["message"])
|
|
||||||
|
|
||||||
return WalletResponse(**result)
|
|
||||||
|
|
||||||
@router.delete("/unlink", response_model=WalletResponse)
|
|
||||||
def unlink_wallet(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
result = wallet_service.unlink_wallet(db=db, user_id=current_user.id)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
raise HTTPException(status_code=400, detail=result["message"])
|
|
||||||
|
|
||||||
return WalletResponse(**result)
|
|
||||||
|
|
||||||
@router.get("/info", response_model=WalletResponse)
|
|
||||||
def get_wallet_info(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
result = wallet_service.get_wallet_info(db=db, user_id=current_user.id)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
raise HTTPException(status_code=400, detail=result["message"])
|
|
||||||
|
|
||||||
return WalletResponse(**result)
|
|
||||||
|
|
||||||
@router.post("/generate-test-wallet")
|
|
||||||
def generate_test_wallet():
|
|
||||||
"""Generate a test wallet for development purposes"""
|
|
||||||
result = wallet_service.generate_test_wallet()
|
|
||||||
return result
|
|
@ -1 +0,0 @@
|
|||||||
# Core modules
|
|
@ -1,57 +0,0 @@
|
|||||||
from typing import Generator, Optional
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.core.security import verify_token
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
|
||||||
) -> User:
|
|
||||||
token = credentials.credentials
|
|
||||||
user_id = verify_token(token)
|
|
||||||
if user_id is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid authentication credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="User not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Inactive user"
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
def get_current_developer(
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
) -> User:
|
|
||||||
if current_user.user_type != "developer":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied: Developer role required"
|
|
||||||
)
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
def get_current_buyer(
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
) -> User:
|
|
||||||
if current_user.user_type != "buyer":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied: Buyer role required"
|
|
||||||
)
|
|
||||||
return current_user
|
|
@ -1,39 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Union, Optional
|
|
||||||
from jose import jwt
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Password hashing
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
# JWT settings
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
||||||
|
|
||||||
def create_access_token(
|
|
||||||
subject: Union[str, Any], expires_delta: timedelta = None
|
|
||||||
) -> str:
|
|
||||||
if expires_delta:
|
|
||||||
expire = datetime.utcnow() + expires_delta
|
|
||||||
else:
|
|
||||||
expire = datetime.utcnow() + timedelta(
|
|
||||||
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
|
|
||||||
)
|
|
||||||
to_encode = {"exp": expire, "sub": str(subject)}
|
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
def verify_token(token: str) -> Optional[str]:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
return payload.get("sub")
|
|
||||||
except jwt.JWTError:
|
|
||||||
return None
|
|
@ -1,3 +0,0 @@
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
@ -1,24 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
DB_DIR = Path("/app") / "storage" / "db"
|
|
||||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
|
|
||||||
|
|
||||||
engine = create_engine(
|
|
||||||
SQLALCHEMY_DATABASE_URL,
|
|
||||||
connect_args={"check_same_thread": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
@ -1,6 +0,0 @@
|
|||||||
from app.models.user import User
|
|
||||||
from app.models.carbon_project import CarbonProject
|
|
||||||
from app.models.carbon_offset import CarbonOffset
|
|
||||||
from app.models.transaction import Transaction
|
|
||||||
|
|
||||||
__all__ = ["User", "CarbonProject", "CarbonOffset", "Transaction"]
|
|
@ -1,29 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Float, ForeignKey
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
class CarbonOffset(Base):
|
|
||||||
__tablename__ = "carbon_offsets"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
serial_number = Column(String, unique=True, nullable=False)
|
|
||||||
vintage_year = Column(Integer, nullable=False)
|
|
||||||
quantity = Column(Integer, nullable=False) # Number of credits
|
|
||||||
status = Column(String, default="available") # "available", "sold", "retired"
|
|
||||||
|
|
||||||
# Blockchain information
|
|
||||||
token_id = Column(String, unique=True, nullable=True)
|
|
||||||
blockchain_hash = Column(String, nullable=True)
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
# Foreign keys
|
|
||||||
project_id = Column(Integer, ForeignKey("carbon_projects.id"), nullable=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
project = relationship("CarbonProject", back_populates="offsets")
|
|
||||||
transactions = relationship("Transaction", back_populates="offset")
|
|
@ -1,44 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Float, ForeignKey
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
class CarbonProject(Base):
|
|
||||||
__tablename__ = "carbon_projects"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
title = Column(String, nullable=False)
|
|
||||||
description = Column(Text, nullable=False)
|
|
||||||
location = Column(String, nullable=False)
|
|
||||||
project_type = Column(String, nullable=False) # "forestry", "renewable_energy", "waste_management", etc.
|
|
||||||
methodology = Column(String, nullable=False) # Certification methodology used
|
|
||||||
|
|
||||||
# Carbon offset details
|
|
||||||
total_credits_available = Column(Integer, nullable=False)
|
|
||||||
credits_sold = Column(Integer, default=0)
|
|
||||||
price_per_credit = Column(Float, nullable=False) # Price in USD
|
|
||||||
|
|
||||||
# Project timeline
|
|
||||||
start_date = Column(DateTime, nullable=False)
|
|
||||||
end_date = Column(DateTime, nullable=False)
|
|
||||||
|
|
||||||
# Verification and status
|
|
||||||
verification_status = Column(String, default="pending") # "pending", "verified", "rejected"
|
|
||||||
verification_document_url = Column(String, nullable=True)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
|
|
||||||
# Blockchain information
|
|
||||||
contract_address = Column(String, nullable=True)
|
|
||||||
token_id = Column(String, nullable=True)
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
# Foreign keys
|
|
||||||
developer_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
developer = relationship("User", back_populates="projects")
|
|
||||||
offsets = relationship("CarbonOffset", back_populates="project")
|
|
@ -1,33 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
class Transaction(Base):
|
|
||||||
__tablename__ = "transactions"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
transaction_hash = Column(String, unique=True, nullable=False)
|
|
||||||
quantity = Column(Integer, nullable=False)
|
|
||||||
price_per_credit = Column(Float, nullable=False)
|
|
||||||
total_amount = Column(Float, nullable=False)
|
|
||||||
|
|
||||||
# Transaction status
|
|
||||||
status = Column(String, default="pending") # "pending", "confirmed", "failed"
|
|
||||||
|
|
||||||
# Blockchain information
|
|
||||||
block_number = Column(Integer, nullable=True)
|
|
||||||
gas_used = Column(Integer, nullable=True)
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
confirmed_at = Column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Foreign keys
|
|
||||||
buyer_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
offset_id = Column(Integer, ForeignKey("carbon_offsets.id"), nullable=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
buyer = relationship("User", back_populates="transactions")
|
|
||||||
offset = relationship("CarbonOffset", back_populates="transactions")
|
|
@ -1,25 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
email = Column(String, unique=True, index=True, nullable=False)
|
|
||||||
hashed_password = Column(String, nullable=False)
|
|
||||||
full_name = Column(String, nullable=False)
|
|
||||||
user_type = Column(String, nullable=False) # "developer" or "buyer"
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
# Blockchain wallet information
|
|
||||||
wallet_address = Column(String, unique=True, nullable=True)
|
|
||||||
wallet_public_key = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
projects = relationship("CarbonProject", back_populates="developer")
|
|
||||||
transactions = relationship("Transaction", back_populates="buyer")
|
|
@ -1 +0,0 @@
|
|||||||
# Schema modules
|
|
@ -1,50 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class CarbonProjectBase(BaseModel):
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
location: str
|
|
||||||
project_type: str
|
|
||||||
methodology: str
|
|
||||||
total_credits_available: int
|
|
||||||
price_per_credit: float
|
|
||||||
start_date: datetime
|
|
||||||
end_date: datetime
|
|
||||||
|
|
||||||
class CarbonProjectCreate(CarbonProjectBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CarbonProjectUpdate(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
location: Optional[str] = None
|
|
||||||
project_type: Optional[str] = None
|
|
||||||
methodology: Optional[str] = None
|
|
||||||
total_credits_available: Optional[int] = None
|
|
||||||
price_per_credit: Optional[float] = None
|
|
||||||
start_date: Optional[datetime] = None
|
|
||||||
end_date: Optional[datetime] = None
|
|
||||||
verification_document_url: Optional[str] = None
|
|
||||||
|
|
||||||
class CarbonProjectResponse(CarbonProjectBase):
|
|
||||||
id: int
|
|
||||||
credits_sold: int
|
|
||||||
verification_status: str
|
|
||||||
verification_document_url: Optional[str] = None
|
|
||||||
is_active: bool
|
|
||||||
contract_address: Optional[str] = None
|
|
||||||
token_id: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
developer_id: int
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class CarbonProjectListResponse(BaseModel):
|
|
||||||
projects: List[CarbonProjectResponse]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
@ -1,34 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class TransactionCreate(BaseModel):
|
|
||||||
offset_id: int
|
|
||||||
quantity: int
|
|
||||||
|
|
||||||
class TransactionResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
transaction_hash: str
|
|
||||||
quantity: int
|
|
||||||
price_per_credit: float
|
|
||||||
total_amount: float
|
|
||||||
status: str
|
|
||||||
block_number: Optional[int] = None
|
|
||||||
gas_used: Optional[int] = None
|
|
||||||
created_at: datetime
|
|
||||||
confirmed_at: Optional[datetime] = None
|
|
||||||
buyer_id: int
|
|
||||||
offset_id: int
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class TransactionListResponse(BaseModel):
|
|
||||||
transactions: List[TransactionResponse]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
class PurchaseRequest(BaseModel):
|
|
||||||
project_id: int
|
|
||||||
quantity: int
|
|
@ -1,38 +0,0 @@
|
|||||||
from pydantic import BaseModel, EmailStr
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
full_name: str
|
|
||||||
user_type: str
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
|
||||||
password: str
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
|
||||||
id: int
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime
|
|
||||||
wallet_address: Optional[str] = None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str
|
|
||||||
|
|
||||||
class WalletLinkRequest(BaseModel):
|
|
||||||
wallet_address: str
|
|
||||||
|
|
||||||
class WalletResponse(BaseModel):
|
|
||||||
success: bool
|
|
||||||
wallet_linked: bool
|
|
||||||
wallet_address: Optional[str] = None
|
|
||||||
balance: Optional[float] = None
|
|
||||||
message: Optional[str] = None
|
|
@ -1 +0,0 @@
|
|||||||
# Service modules
|
|
@ -1,168 +0,0 @@
|
|||||||
from web3 import Web3
|
|
||||||
from eth_account import Account
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class BlockchainService:
|
|
||||||
def __init__(self):
|
|
||||||
# Use environment variable for RPC URL (defaults to local for development)
|
|
||||||
self.rpc_url = os.getenv("BLOCKCHAIN_RPC_URL", "http://localhost:8545")
|
|
||||||
self.w3 = Web3(Web3.HTTPProvider(self.rpc_url))
|
|
||||||
self.contract_abi = self._get_carbon_token_abi()
|
|
||||||
|
|
||||||
def _get_carbon_token_abi(self) -> list:
|
|
||||||
# Simplified ABI for a carbon credit token contract
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"inputs": [
|
|
||||||
{"name": "to", "type": "address"},
|
|
||||||
{"name": "tokenId", "type": "uint256"},
|
|
||||||
{"name": "credits", "type": "uint256"}
|
|
||||||
],
|
|
||||||
"name": "mintCarbonCredit",
|
|
||||||
"outputs": [{"name": "", "type": "bool"}],
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"inputs": [
|
|
||||||
{"name": "from", "type": "address"},
|
|
||||||
{"name": "to", "type": "address"},
|
|
||||||
{"name": "tokenId", "type": "uint256"}
|
|
||||||
],
|
|
||||||
"name": "transferFrom",
|
|
||||||
"outputs": [{"name": "", "type": "bool"}],
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"inputs": [{"name": "tokenId", "type": "uint256"}],
|
|
||||||
"name": "ownerOf",
|
|
||||||
"outputs": [{"name": "", "type": "address"}],
|
|
||||||
"type": "function"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def validate_wallet_address(self, address: str) -> bool:
|
|
||||||
"""Validate if the provided address is a valid Ethereum address"""
|
|
||||||
try:
|
|
||||||
return Web3.is_address(address) and Web3.is_checksum_address(Web3.to_checksum_address(address))
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def generate_wallet(self) -> Dict[str, str]:
|
|
||||||
"""Generate a new wallet for testing purposes"""
|
|
||||||
account = Account.create()
|
|
||||||
return {
|
|
||||||
"address": account.address,
|
|
||||||
"private_key": account.key.hex(),
|
|
||||||
"public_key": account.address # In Ethereum, address is derived from public key
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_wallet_balance(self, address: str) -> Optional[float]:
|
|
||||||
"""Get ETH balance for a wallet address"""
|
|
||||||
try:
|
|
||||||
if not self.validate_wallet_address(address):
|
|
||||||
return None
|
|
||||||
|
|
||||||
balance_wei = self.w3.eth.get_balance(Web3.to_checksum_address(address))
|
|
||||||
balance_eth = self.w3.from_wei(balance_wei, 'ether')
|
|
||||||
return float(balance_eth)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting balance for {address}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def create_carbon_token_transaction(
|
|
||||||
self,
|
|
||||||
contract_address: str,
|
|
||||||
from_address: str,
|
|
||||||
to_address: str,
|
|
||||||
token_id: int,
|
|
||||||
private_key: str = None
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Create a transaction to transfer carbon credits"""
|
|
||||||
try:
|
|
||||||
if not all([
|
|
||||||
self.validate_wallet_address(contract_address),
|
|
||||||
self.validate_wallet_address(from_address),
|
|
||||||
self.validate_wallet_address(to_address)
|
|
||||||
]):
|
|
||||||
return None
|
|
||||||
|
|
||||||
contract = self.w3.eth.contract(
|
|
||||||
address=Web3.to_checksum_address(contract_address),
|
|
||||||
abi=self.contract_abi
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build transaction
|
|
||||||
transaction = contract.functions.transferFrom(
|
|
||||||
Web3.to_checksum_address(from_address),
|
|
||||||
Web3.to_checksum_address(to_address),
|
|
||||||
token_id
|
|
||||||
).build_transaction({
|
|
||||||
'from': Web3.to_checksum_address(from_address),
|
|
||||||
'gas': 200000,
|
|
||||||
'gasPrice': self.w3.to_wei('20', 'gwei'),
|
|
||||||
'nonce': self.w3.eth.get_transaction_count(Web3.to_checksum_address(from_address))
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"transaction": transaction,
|
|
||||||
"contract_address": contract_address,
|
|
||||||
"from_address": from_address,
|
|
||||||
"to_address": to_address,
|
|
||||||
"token_id": token_id,
|
|
||||||
"created_at": datetime.utcnow().isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error creating transaction: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def sign_and_send_transaction(self, transaction_data: Dict[str, Any], private_key: str) -> Optional[str]:
|
|
||||||
"""Sign and send a transaction to the blockchain"""
|
|
||||||
try:
|
|
||||||
transaction = transaction_data["transaction"]
|
|
||||||
signed_txn = self.w3.eth.account.sign_transaction(transaction, private_key)
|
|
||||||
tx_hash = self.w3.eth.send_raw_transaction(signed_txn.rawTransaction)
|
|
||||||
return tx_hash.hex()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error signing/sending transaction: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_transaction_receipt(self, tx_hash: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get transaction receipt from blockchain"""
|
|
||||||
try:
|
|
||||||
receipt = self.w3.eth.get_transaction_receipt(tx_hash)
|
|
||||||
return {
|
|
||||||
"transaction_hash": receipt["transactionHash"].hex(),
|
|
||||||
"block_number": receipt["blockNumber"],
|
|
||||||
"gas_used": receipt["gasUsed"],
|
|
||||||
"status": receipt["status"] # 1 for success, 0 for failure
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting transaction receipt: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def verify_token_ownership(self, contract_address: str, token_id: int, owner_address: str) -> bool:
|
|
||||||
"""Verify if an address owns a specific token"""
|
|
||||||
try:
|
|
||||||
if not all([
|
|
||||||
self.validate_wallet_address(contract_address),
|
|
||||||
self.validate_wallet_address(owner_address)
|
|
||||||
]):
|
|
||||||
return False
|
|
||||||
|
|
||||||
contract = self.w3.eth.contract(
|
|
||||||
address=Web3.to_checksum_address(contract_address),
|
|
||||||
abi=self.contract_abi
|
|
||||||
)
|
|
||||||
|
|
||||||
actual_owner = contract.functions.ownerOf(token_id).call()
|
|
||||||
return actual_owner.lower() == owner_address.lower()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error verifying ownership: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
blockchain_service = BlockchainService()
|
|
@ -1,130 +0,0 @@
|
|||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services.blockchain import blockchain_service
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
|
|
||||||
class WalletService:
|
|
||||||
|
|
||||||
def link_wallet(self, db: Session, user_id: int, wallet_address: str) -> Dict[str, Any]:
|
|
||||||
"""Link a wallet address to a user account"""
|
|
||||||
|
|
||||||
# Validate wallet address format
|
|
||||||
if not blockchain_service.validate_wallet_address(wallet_address):
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "Invalid wallet address format"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if wallet is already linked to another user
|
|
||||||
existing_user = db.query(User).filter(
|
|
||||||
User.wallet_address == wallet_address,
|
|
||||||
User.id != user_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_user:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "Wallet address is already linked to another account"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get user
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "User not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update user wallet information
|
|
||||||
user.wallet_address = wallet_address
|
|
||||||
user.wallet_public_key = wallet_address # In Ethereum, address is derived from public key
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.commit()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Wallet linked successfully",
|
|
||||||
"wallet_address": wallet_address
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": f"Database error: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
def unlink_wallet(self, db: Session, user_id: int) -> Dict[str, Any]:
|
|
||||||
"""Unlink wallet from user account"""
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "User not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
if not user.wallet_address:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "No wallet linked to this account"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clear wallet information
|
|
||||||
user.wallet_address = None
|
|
||||||
user.wallet_public_key = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.commit()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Wallet unlinked successfully"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": f"Database error: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_wallet_info(self, db: Session, user_id: int) -> Dict[str, Any]:
|
|
||||||
"""Get wallet information for a user"""
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "User not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
if not user.wallet_address:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"wallet_linked": False,
|
|
||||||
"wallet_address": None,
|
|
||||||
"balance": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get wallet balance from blockchain
|
|
||||||
balance = blockchain_service.get_wallet_balance(user.wallet_address)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"wallet_linked": True,
|
|
||||||
"wallet_address": user.wallet_address,
|
|
||||||
"balance": balance
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_test_wallet(self) -> Dict[str, Any]:
|
|
||||||
"""Generate a test wallet for development purposes"""
|
|
||||||
|
|
||||||
wallet_data = blockchain_service.generate_wallet()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Test wallet generated successfully",
|
|
||||||
"wallet_data": wallet_data,
|
|
||||||
"warning": "This is for testing only. Keep private key secure!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
wallet_service = WalletService()
|
|
88
main.py
88
main.py
@ -1,88 +0,0 @@
|
|||||||
from fastapi import FastAPI, Depends
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.openapi.utils import get_openapi
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.db.session import get_db, engine
|
|
||||||
from app.db.base import Base
|
|
||||||
from app.api import auth, wallet, projects, trading
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Create database tables
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="Carbon Offset Trading Platform",
|
|
||||||
description="A blockchain-enabled platform for trading carbon offsets between project developers and buyers",
|
|
||||||
version="1.0.0",
|
|
||||||
docs_url="/docs",
|
|
||||||
redoc_url="/redoc"
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS configuration
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Include routers
|
|
||||||
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
|
||||||
app.include_router(wallet.router, prefix="/wallet", tags=["Wallet"])
|
|
||||||
app.include_router(projects.router, prefix="/projects", tags=["Projects"])
|
|
||||||
app.include_router(trading.router, prefix="/trading", tags=["Trading"])
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
"""Base endpoint with platform information"""
|
|
||||||
return {
|
|
||||||
"title": "Carbon Offset Trading Platform",
|
|
||||||
"description": "A blockchain-enabled platform for trading carbon offsets",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"documentation": "/docs",
|
|
||||||
"health_check": "/health"
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check(db: Session = Depends(get_db)):
|
|
||||||
"""Health check endpoint"""
|
|
||||||
try:
|
|
||||||
# Test database connection
|
|
||||||
db.execute("SELECT 1")
|
|
||||||
db_status = "healthy"
|
|
||||||
except Exception as e:
|
|
||||||
db_status = f"unhealthy: {str(e)}"
|
|
||||||
|
|
||||||
# Check environment variables
|
|
||||||
env_status = "healthy"
|
|
||||||
required_envs = ["SECRET_KEY"]
|
|
||||||
missing_envs = [env for env in required_envs if not os.getenv(env)]
|
|
||||||
if missing_envs:
|
|
||||||
env_status = f"missing environment variables: {', '.join(missing_envs)}"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "healthy" if db_status == "healthy" and env_status == "healthy" else "unhealthy",
|
|
||||||
"database": db_status,
|
|
||||||
"environment": env_status,
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Custom OpenAPI schema
|
|
||||||
def custom_openapi():
|
|
||||||
if app.openapi_schema:
|
|
||||||
return app.openapi_schema
|
|
||||||
openapi_schema = get_openapi(
|
|
||||||
title="Carbon Offset Trading Platform API",
|
|
||||||
version="1.0.0",
|
|
||||||
description="A blockchain-enabled platform for trading carbon offsets between project developers and buyers",
|
|
||||||
routes=app.routes,
|
|
||||||
)
|
|
||||||
app.openapi_schema = openapi_schema
|
|
||||||
return app.openapi_schema
|
|
||||||
|
|
||||||
app.openapi = custom_openapi
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
59
openapi.json
59
openapi.json
@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"openapi": "3.1.0",
|
|
||||||
"info": {
|
|
||||||
"title": "Carbon Offset Trading Platform API",
|
|
||||||
"description": "A blockchain-enabled platform for trading carbon offsets between project developers and buyers",
|
|
||||||
"version": "1.0.0"
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Root endpoint",
|
|
||||||
"description": "Returns platform information and available endpoints",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Platform information",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"title": {"type": "string"},
|
|
||||||
"description": {"type": "string"},
|
|
||||||
"version": {"type": "string"},
|
|
||||||
"documentation": {"type": "string"},
|
|
||||||
"health_check": {"type": "string"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/health": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Health check endpoint",
|
|
||||||
"description": "Returns the health status of the application and its dependencies",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Health status information",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"status": {"type": "string"},
|
|
||||||
"database": {"type": "string"},
|
|
||||||
"environment": {"type": "string"},
|
|
||||||
"version": {"type": "string"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
66
package.json
Normal file
66
package.json
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "carbon-offset-trading-platform",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A blockchain-enabled carbon offset trading platform for project developers and buyers",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"db:migrate": "npx prisma migrate dev",
|
||||||
|
"db:generate": "npx prisma generate",
|
||||||
|
"db:studio": "npx prisma studio",
|
||||||
|
"lint": "eslint src/**/*.ts",
|
||||||
|
"lint:fix": "eslint src/**/*.ts --fix",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"carbon-offset",
|
||||||
|
"blockchain",
|
||||||
|
"trading",
|
||||||
|
"sustainability",
|
||||||
|
"ethereum",
|
||||||
|
"express",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "BackendIM",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"prisma": "^5.6.0",
|
||||||
|
"@prisma/client": "^5.6.0",
|
||||||
|
"web3": "^4.2.2",
|
||||||
|
"ethers": "^6.8.1",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"joi": "^17.11.0",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^20.9.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||||
|
"@typescript-eslint/parser": "^6.12.0",
|
||||||
|
"eslint": "^8.54.0",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"tsx": "^4.6.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"@types/jest": "^29.5.8",
|
||||||
|
"ts-jest": "^29.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
143
prisma/schema.prisma
Normal file
143
prisma/schema.prisma
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
fullName String @map("full_name")
|
||||||
|
userType UserType @map("user_type")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Blockchain wallet information
|
||||||
|
walletAddress String? @unique @map("wallet_address")
|
||||||
|
walletPublicKey String? @map("wallet_public_key")
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
projects CarbonProject[] @relation("ProjectDeveloper")
|
||||||
|
transactions Transaction[] @relation("TransactionBuyer")
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CarbonProject {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
location String
|
||||||
|
projectType String @map("project_type")
|
||||||
|
methodology String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Carbon offset details
|
||||||
|
totalCreditsAvailable Int @map("total_credits_available")
|
||||||
|
creditsSold Int @default(0) @map("credits_sold")
|
||||||
|
pricePerCredit Float @map("price_per_credit")
|
||||||
|
|
||||||
|
// Project timeline
|
||||||
|
startDate DateTime @map("start_date")
|
||||||
|
endDate DateTime @map("end_date")
|
||||||
|
|
||||||
|
// Verification and status
|
||||||
|
verificationStatus VerificationStatus @default(PENDING) @map("verification_status")
|
||||||
|
verificationDocumentUrl String? @map("verification_document_url")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
// Blockchain information
|
||||||
|
contractAddress String? @map("contract_address")
|
||||||
|
tokenId String? @map("token_id")
|
||||||
|
|
||||||
|
// Foreign keys
|
||||||
|
developerId Int @map("developer_id")
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
developer User @relation("ProjectDeveloper", fields: [developerId], references: [id])
|
||||||
|
offsets CarbonOffset[]
|
||||||
|
|
||||||
|
@@map("carbon_projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CarbonOffset {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
serialNumber String @unique @map("serial_number")
|
||||||
|
vintageYear Int @map("vintage_year")
|
||||||
|
quantity Int
|
||||||
|
status OffsetStatus @default(AVAILABLE)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Blockchain information
|
||||||
|
tokenId String? @unique @map("token_id")
|
||||||
|
blockchainHash String? @map("blockchain_hash")
|
||||||
|
|
||||||
|
// Foreign keys
|
||||||
|
projectId Int @map("project_id")
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
project CarbonProject @relation(fields: [projectId], references: [id])
|
||||||
|
transactions Transaction[]
|
||||||
|
|
||||||
|
@@map("carbon_offsets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Transaction {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
transactionHash String @unique @map("transaction_hash")
|
||||||
|
quantity Int
|
||||||
|
pricePerCredit Float @map("price_per_credit")
|
||||||
|
totalAmount Float @map("total_amount")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Transaction status
|
||||||
|
status TransactionStatus @default(PENDING)
|
||||||
|
confirmedAt DateTime? @map("confirmed_at")
|
||||||
|
|
||||||
|
// Blockchain information
|
||||||
|
blockNumber Int? @map("block_number")
|
||||||
|
gasUsed Int? @map("gas_used")
|
||||||
|
|
||||||
|
// Foreign keys
|
||||||
|
buyerId Int @map("buyer_id")
|
||||||
|
offsetId Int @map("offset_id")
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
buyer User @relation("TransactionBuyer", fields: [buyerId], references: [id])
|
||||||
|
offset CarbonOffset @relation(fields: [offsetId], references: [id])
|
||||||
|
|
||||||
|
@@map("transactions")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserType {
|
||||||
|
DEVELOPER
|
||||||
|
BUYER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationStatus {
|
||||||
|
PENDING
|
||||||
|
VERIFIED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OffsetStatus {
|
||||||
|
AVAILABLE
|
||||||
|
SOLD
|
||||||
|
RETIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransactionStatus {
|
||||||
|
PENDING
|
||||||
|
CONFIRMED
|
||||||
|
FAILED
|
||||||
|
}
|
@ -1,13 +0,0 @@
|
|||||||
fastapi==0.104.1
|
|
||||||
uvicorn==0.24.0
|
|
||||||
sqlalchemy==2.0.23
|
|
||||||
alembic==1.12.1
|
|
||||||
python-multipart==0.0.6
|
|
||||||
python-jose[cryptography]==3.3.0
|
|
||||||
passlib[bcrypt]==1.7.4
|
|
||||||
pydantic-settings==2.0.3
|
|
||||||
web3==6.11.3
|
|
||||||
eth-account==0.9.0
|
|
||||||
cryptography==41.0.7
|
|
||||||
ruff==0.1.5
|
|
||||||
python-dotenv==1.0.0
|
|
76
src/middleware/auth.ts
Normal file
76
src/middleware/auth.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { AuthUtils } from '@/utils/auth';
|
||||||
|
import { UserType } from '@/types';
|
||||||
|
import { prisma } from '@/utils/database';
|
||||||
|
|
||||||
|
export const authenticateToken = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Access token required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = AuthUtils.verifyAccessToken(token);
|
||||||
|
if (!decoded) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid or expired token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user still exists and is active
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'User not found or inactive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user info to request
|
||||||
|
req.user = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
userType: user.userType
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication middleware error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during authentication'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireUserType = (requiredType: UserType) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Authentication required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user.userType !== requiredType) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: `Access denied: ${requiredType} role required`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireDeveloper = requireUserType(UserType.DEVELOPER);
|
||||||
|
export const requireBuyer = requireUserType(UserType.BUYER);
|
46
src/middleware/error.ts
Normal file
46
src/middleware/error.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ApiResponse } from '@/types';
|
||||||
|
|
||||||
|
export interface AppError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
isOperational?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createError = (message: string, statusCode: number = 500): AppError => {
|
||||||
|
const error: AppError = new Error(message);
|
||||||
|
error.statusCode = statusCode;
|
||||||
|
error.isOperational = true;
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const errorHandler = (
|
||||||
|
error: AppError,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
console.error('Error:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
url: req.url,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusCode = error.statusCode || 500;
|
||||||
|
const message = error.isOperational ? error.message : 'Internal Server Error';
|
||||||
|
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
...(process.env.NODE_ENV === 'development' && { stack: error.stack })
|
||||||
|
} as ApiResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const notFoundHandler = (req: Request, res: Response) => {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: `Route ${req.method} ${req.path} not found`
|
||||||
|
} as ApiResponse);
|
||||||
|
};
|
60
src/middleware/security.ts
Normal file
60
src/middleware/security.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cors from 'cors';
|
||||||
|
|
||||||
|
// Rate limiting configuration
|
||||||
|
export const createRateLimiter = () => {
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes
|
||||||
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'), // limit each IP
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: 'Too many requests from this IP, please try again later'
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
export const corsOptions = {
|
||||||
|
origin: process.env.CORS_ORIGIN === '*' ? true : process.env.CORS_ORIGIN?.split(',') || 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
optionsSuccessStatus: 200,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helmet configuration for security headers
|
||||||
|
export const helmetOptions = {
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
fontSrc: ["'self'"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
mediaSrc: ["'self'"],
|
||||||
|
frameSrc: ["'none'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Security middleware setup
|
||||||
|
export const setupSecurity = (app: any) => {
|
||||||
|
// Basic security headers
|
||||||
|
app.use(helmet(helmetOptions));
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
app.use('/api/', createRateLimiter());
|
||||||
|
|
||||||
|
// Trust proxy (if behind reverse proxy)
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
};
|
186
src/middleware/validation.ts
Normal file
186
src/middleware/validation.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import Joi from 'joi';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { UserType } from '@/types';
|
||||||
|
|
||||||
|
// User validation schemas
|
||||||
|
export const registerSchema = Joi.object({
|
||||||
|
email: Joi.string().email().required().messages({
|
||||||
|
'string.email': 'Please provide a valid email address',
|
||||||
|
'any.required': 'Email is required'
|
||||||
|
}),
|
||||||
|
password: Joi.string().min(8).pattern(new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)')).required().messages({
|
||||||
|
'string.min': 'Password must be at least 8 characters long',
|
||||||
|
'string.pattern.base': 'Password must contain at least one uppercase letter, one lowercase letter, and one number',
|
||||||
|
'any.required': 'Password is required'
|
||||||
|
}),
|
||||||
|
fullName: Joi.string().min(2).max(100).required().messages({
|
||||||
|
'string.min': 'Full name must be at least 2 characters long',
|
||||||
|
'string.max': 'Full name must not exceed 100 characters',
|
||||||
|
'any.required': 'Full name is required'
|
||||||
|
}),
|
||||||
|
userType: Joi.string().valid(...Object.values(UserType)).required().messages({
|
||||||
|
'any.only': 'User type must be either DEVELOPER or BUYER',
|
||||||
|
'any.required': 'User type is required'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginSchema = Joi.object({
|
||||||
|
email: Joi.string().email().required().messages({
|
||||||
|
'string.email': 'Please provide a valid email address',
|
||||||
|
'any.required': 'Email is required'
|
||||||
|
}),
|
||||||
|
password: Joi.string().required().messages({
|
||||||
|
'any.required': 'Password is required'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wallet validation schemas
|
||||||
|
export const walletLinkSchema = Joi.object({
|
||||||
|
walletAddress: Joi.string().pattern(new RegExp('^0x[a-fA-F0-9]{40}$')).required().messages({
|
||||||
|
'string.pattern.base': 'Please provide a valid Ethereum wallet address',
|
||||||
|
'any.required': 'Wallet address is required'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Project validation schemas
|
||||||
|
export const createProjectSchema = Joi.object({
|
||||||
|
title: Joi.string().min(5).max(200).required().messages({
|
||||||
|
'string.min': 'Project title must be at least 5 characters long',
|
||||||
|
'string.max': 'Project title must not exceed 200 characters',
|
||||||
|
'any.required': 'Project title is required'
|
||||||
|
}),
|
||||||
|
description: Joi.string().min(20).max(2000).required().messages({
|
||||||
|
'string.min': 'Project description must be at least 20 characters long',
|
||||||
|
'string.max': 'Project description must not exceed 2000 characters',
|
||||||
|
'any.required': 'Project description is required'
|
||||||
|
}),
|
||||||
|
location: Joi.string().min(2).max(100).required().messages({
|
||||||
|
'string.min': 'Location must be at least 2 characters long',
|
||||||
|
'string.max': 'Location must not exceed 100 characters',
|
||||||
|
'any.required': 'Location is required'
|
||||||
|
}),
|
||||||
|
projectType: Joi.string().min(2).max(50).required().messages({
|
||||||
|
'string.min': 'Project type must be at least 2 characters long',
|
||||||
|
'string.max': 'Project type must not exceed 50 characters',
|
||||||
|
'any.required': 'Project type is required'
|
||||||
|
}),
|
||||||
|
methodology: Joi.string().min(2).max(100).required().messages({
|
||||||
|
'string.min': 'Methodology must be at least 2 characters long',
|
||||||
|
'string.max': 'Methodology must not exceed 100 characters',
|
||||||
|
'any.required': 'Methodology is required'
|
||||||
|
}),
|
||||||
|
totalCreditsAvailable: Joi.number().integer().min(1).max(1000000).required().messages({
|
||||||
|
'number.base': 'Total credits available must be a number',
|
||||||
|
'number.integer': 'Total credits available must be an integer',
|
||||||
|
'number.min': 'Total credits available must be at least 1',
|
||||||
|
'number.max': 'Total credits available must not exceed 1,000,000',
|
||||||
|
'any.required': 'Total credits available is required'
|
||||||
|
}),
|
||||||
|
pricePerCredit: Joi.number().min(0.01).max(10000).required().messages({
|
||||||
|
'number.base': 'Price per credit must be a number',
|
||||||
|
'number.min': 'Price per credit must be at least $0.01',
|
||||||
|
'number.max': 'Price per credit must not exceed $10,000',
|
||||||
|
'any.required': 'Price per credit is required'
|
||||||
|
}),
|
||||||
|
startDate: Joi.date().iso().required().messages({
|
||||||
|
'date.base': 'Start date must be a valid date',
|
||||||
|
'date.format': 'Start date must be in ISO format',
|
||||||
|
'any.required': 'Start date is required'
|
||||||
|
}),
|
||||||
|
endDate: Joi.date().iso().greater(Joi.ref('startDate')).required().messages({
|
||||||
|
'date.base': 'End date must be a valid date',
|
||||||
|
'date.format': 'End date must be in ISO format',
|
||||||
|
'date.greater': 'End date must be after start date',
|
||||||
|
'any.required': 'End date is required'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateProjectSchema = Joi.object({
|
||||||
|
title: Joi.string().min(5).max(200).optional(),
|
||||||
|
description: Joi.string().min(20).max(2000).optional(),
|
||||||
|
location: Joi.string().min(2).max(100).optional(),
|
||||||
|
projectType: Joi.string().min(2).max(50).optional(),
|
||||||
|
methodology: Joi.string().min(2).max(100).optional(),
|
||||||
|
totalCreditsAvailable: Joi.number().integer().min(1).max(1000000).optional(),
|
||||||
|
pricePerCredit: Joi.number().min(0.01).max(10000).optional(),
|
||||||
|
startDate: Joi.date().iso().optional(),
|
||||||
|
endDate: Joi.date().iso().optional(),
|
||||||
|
verificationDocumentUrl: Joi.string().uri().optional().messages({
|
||||||
|
'string.uri': 'Verification document URL must be a valid URL'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transaction validation schemas
|
||||||
|
export const purchaseSchema = Joi.object({
|
||||||
|
projectId: Joi.number().integer().min(1).required().messages({
|
||||||
|
'number.base': 'Project ID must be a number',
|
||||||
|
'number.integer': 'Project ID must be an integer',
|
||||||
|
'number.min': 'Project ID must be at least 1',
|
||||||
|
'any.required': 'Project ID is required'
|
||||||
|
}),
|
||||||
|
quantity: Joi.number().integer().min(1).max(100000).required().messages({
|
||||||
|
'number.base': 'Quantity must be a number',
|
||||||
|
'number.integer': 'Quantity must be an integer',
|
||||||
|
'number.min': 'Quantity must be at least 1',
|
||||||
|
'number.max': 'Quantity must not exceed 100,000',
|
||||||
|
'any.required': 'Quantity is required'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination validation schema
|
||||||
|
export const paginationSchema = Joi.object({
|
||||||
|
page: Joi.number().integer().min(1).default(1).optional(),
|
||||||
|
pageSize: Joi.number().integer().min(1).max(100).default(10).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation middleware factory
|
||||||
|
export const validate = (schema: Joi.ObjectSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const { error, value } = schema.validate(req.body, {
|
||||||
|
abortEarly: false,
|
||||||
|
stripUnknown: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errors = error.details.map(detail => ({
|
||||||
|
field: detail.path.join('.'),
|
||||||
|
message: detail.message
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.body = value;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query validation middleware
|
||||||
|
export const validateQuery = (schema: Joi.ObjectSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const { error, value } = schema.validate(req.query, {
|
||||||
|
abortEarly: false,
|
||||||
|
stripUnknown: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errors = error.details.map(detail => ({
|
||||||
|
field: detail.path.join('.'),
|
||||||
|
message: detail.message
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Query validation failed',
|
||||||
|
details: errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.query = value;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
131
src/routes/auth.ts
Normal file
131
src/routes/auth.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { prisma } from '@/utils/database';
|
||||||
|
import { AuthUtils } from '@/utils/auth';
|
||||||
|
import { validate, registerSchema, loginSchema } from '@/middleware/validation';
|
||||||
|
import { CreateUserRequest, LoginRequest, UserResponse, ApiResponse } from '@/types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Register endpoint
|
||||||
|
router.post('/register', validate(registerSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password, fullName, userType }: CreateUserRequest = req.body;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Email already registered'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await AuthUtils.hashPassword(password);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
fullName,
|
||||||
|
userType
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
fullName: true,
|
||||||
|
userType: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
walletAddress: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: user,
|
||||||
|
message: 'User registered successfully'
|
||||||
|
} as ApiResponse<UserResponse>);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during registration'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
router.post('/login', validate(loginSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password }: LoginRequest = req.body;
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid credentials'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await AuthUtils.verifyPassword(password, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid credentials'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Account is deactivated'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
const token = AuthUtils.generateAccessToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
userType: user.userType
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accessToken: token,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
fullName: user.fullName,
|
||||||
|
userType: user.userType,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
walletAddress: user.walletAddress
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: 'Login successful'
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during login'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
292
src/routes/projects.ts
Normal file
292
src/routes/projects.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { authenticateToken, requireDeveloper } from '@/middleware/auth';
|
||||||
|
import { validate, validateQuery, createProjectSchema, updateProjectSchema, paginationSchema } from '@/middleware/validation';
|
||||||
|
import { prisma } from '@/utils/database';
|
||||||
|
import { CreateProjectRequest, UpdateProjectRequest, ApiResponse, PaginationOptions, FilterOptions } from '@/types';
|
||||||
|
import { VerificationStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Create project endpoint (Developer only)
|
||||||
|
router.post('/', authenticateToken, requireDeveloper, validate(createProjectSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const projectData: CreateProjectRequest = req.body;
|
||||||
|
const developerId = req.user!.id;
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
const project = await prisma.carbonProject.create({
|
||||||
|
data: {
|
||||||
|
...projectData,
|
||||||
|
developerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create initial carbon offset
|
||||||
|
await prisma.carbonOffset.create({
|
||||||
|
data: {
|
||||||
|
serialNumber: `CO${project.id}-${projectData.totalCreditsAvailable}`,
|
||||||
|
vintageYear: new Date(projectData.startDate).getFullYear(),
|
||||||
|
quantity: projectData.totalCreditsAvailable,
|
||||||
|
projectId: project.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: project,
|
||||||
|
message: 'Project created successfully'
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Project creation error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during project creation'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all projects endpoint
|
||||||
|
router.get('/', validateQuery(paginationSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10 } = req.query as PaginationOptions;
|
||||||
|
const { projectType, verificationStatus } = req.query as FilterOptions;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: any = { isActive: true };
|
||||||
|
if (projectType) where.projectType = projectType;
|
||||||
|
if (verificationStatus) where.verificationStatus = verificationStatus as VerificationStatus;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await prisma.carbonProject.count({ where });
|
||||||
|
|
||||||
|
// Get projects with pagination
|
||||||
|
const projects = await prisma.carbonProject.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
developer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fullName: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
projects,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Projects list error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during projects retrieval'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get my projects endpoint (Developer only)
|
||||||
|
router.get('/my-projects', authenticateToken, requireDeveloper, validateQuery(paginationSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10 } = req.query as PaginationOptions;
|
||||||
|
const developerId = req.user!.id;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await prisma.carbonProject.count({
|
||||||
|
where: { developerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get projects with pagination
|
||||||
|
const projects = await prisma.carbonProject.findMany({
|
||||||
|
where: { developerId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
projects,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('My projects error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during projects retrieval'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get specific project endpoint
|
||||||
|
router.get('/:projectId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const projectId = parseInt(req.params.projectId);
|
||||||
|
|
||||||
|
if (isNaN(projectId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid project ID'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.carbonProject.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
developer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fullName: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
offsets: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
serialNumber: true,
|
||||||
|
quantity: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: project
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Project retrieval error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during project retrieval'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update project endpoint (Developer only - own projects)
|
||||||
|
router.put('/:projectId', authenticateToken, requireDeveloper, validate(updateProjectSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const projectId = parseInt(req.params.projectId);
|
||||||
|
const updateData: UpdateProjectRequest = req.body;
|
||||||
|
const developerId = req.user!.id;
|
||||||
|
|
||||||
|
if (isNaN(projectId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid project ID'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if project exists and belongs to user
|
||||||
|
const existingProject = await prisma.carbonProject.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
developerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingProject) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found or access denied'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project
|
||||||
|
const project = await prisma.carbonProject.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: project,
|
||||||
|
message: 'Project updated successfully'
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Project update error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during project update'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete project endpoint (Developer only - own projects)
|
||||||
|
router.delete('/:projectId', authenticateToken, requireDeveloper, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const projectId = parseInt(req.params.projectId);
|
||||||
|
const developerId = req.user!.id;
|
||||||
|
|
||||||
|
if (isNaN(projectId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid project ID'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if project exists and belongs to user
|
||||||
|
const existingProject = await prisma.carbonProject.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
developerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingProject) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found or access denied'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete - mark as inactive
|
||||||
|
await prisma.carbonProject.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: { isActive: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Project deleted successfully'
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Project deletion error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during project deletion'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
329
src/routes/trading.ts
Normal file
329
src/routes/trading.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { authenticateToken, requireBuyer } from '@/middleware/auth';
|
||||||
|
import { validate, validateQuery, purchaseSchema, paginationSchema } from '@/middleware/validation';
|
||||||
|
import { prisma } from '@/utils/database';
|
||||||
|
import { PurchaseRequest, ApiResponse, PaginationOptions, FilterOptions } from '@/types';
|
||||||
|
import { TransactionStatus, OffsetStatus } from '@prisma/client';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Purchase carbon offsets endpoint (Buyer only)
|
||||||
|
router.post('/purchase', authenticateToken, requireBuyer, validate(purchaseSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { projectId, quantity }: PurchaseRequest = req.body;
|
||||||
|
const buyerId = req.user!.id;
|
||||||
|
|
||||||
|
// Check if user has wallet linked
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: buyerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.walletAddress) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Wallet must be linked to purchase carbon offsets'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project
|
||||||
|
const project = await prisma.carbonProject.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
isActive: true,
|
||||||
|
verificationStatus: 'VERIFIED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found or not verified'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough credits are available
|
||||||
|
const availableCredits = project.totalCreditsAvailable - project.creditsSold;
|
||||||
|
if (quantity > availableCredits) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Not enough credits available. Available: ${availableCredits}`
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available offset
|
||||||
|
const offset = await prisma.carbonOffset.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
status: OffsetStatus.AVAILABLE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!offset) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No available carbon offsets for this project'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total amount
|
||||||
|
const totalAmount = quantity * project.pricePerCredit;
|
||||||
|
|
||||||
|
// Create transaction in a database transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create transaction record
|
||||||
|
const transactionHash = `tx_${uuidv4().replace(/-/g, '').substring(0, 16)}`;
|
||||||
|
|
||||||
|
const transaction = await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
transactionHash,
|
||||||
|
quantity,
|
||||||
|
pricePerCredit: project.pricePerCredit,
|
||||||
|
totalAmount,
|
||||||
|
buyerId,
|
||||||
|
offsetId: offset.id,
|
||||||
|
status: TransactionStatus.PENDING
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update project credits sold
|
||||||
|
await tx.carbonProject.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: {
|
||||||
|
creditsSold: { increment: quantity }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update offset quantity or status
|
||||||
|
if (offset.quantity <= quantity) {
|
||||||
|
await tx.carbonOffset.update({
|
||||||
|
where: { id: offset.id },
|
||||||
|
data: { status: OffsetStatus.SOLD }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update existing offset and create new offset for sold quantity
|
||||||
|
await tx.carbonOffset.update({
|
||||||
|
where: { id: offset.id },
|
||||||
|
data: { quantity: { decrement: quantity } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.carbonOffset.create({
|
||||||
|
data: {
|
||||||
|
serialNumber: `CO${projectId}-sold-${transaction.id}`,
|
||||||
|
vintageYear: offset.vintageYear,
|
||||||
|
quantity,
|
||||||
|
status: OffsetStatus.SOLD,
|
||||||
|
projectId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate blockchain transaction confirmation
|
||||||
|
// In a real implementation, you would integrate with actual blockchain
|
||||||
|
const confirmedTransaction = await tx.transaction.update({
|
||||||
|
where: { id: transaction.id },
|
||||||
|
data: {
|
||||||
|
status: TransactionStatus.CONFIRMED,
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
blockNumber: Math.floor(Math.random() * 1000000) + 12000000, // Simulated
|
||||||
|
gasUsed: 21000 // Simulated
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return confirmedTransaction;
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Carbon offsets purchased successfully'
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Purchase error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during purchase'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's transactions endpoint
|
||||||
|
router.get('/my-transactions', authenticateToken, validateQuery(paginationSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10 } = req.query as PaginationOptions;
|
||||||
|
const { status } = req.query as FilterOptions;
|
||||||
|
const buyerId = req.user!.id;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: any = { buyerId };
|
||||||
|
if (status) where.status = status as TransactionStatus;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await prisma.transaction.count({ where });
|
||||||
|
|
||||||
|
// Get transactions with pagination
|
||||||
|
const transactions = await prisma.transaction.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
offset: {
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
projectType: true,
|
||||||
|
location: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
transactions,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Transactions retrieval error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during transactions retrieval'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get specific transaction endpoint
|
||||||
|
router.get('/transactions/:transactionId', authenticateToken, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const transactionId = parseInt(req.params.transactionId);
|
||||||
|
const userId = req.user!.id;
|
||||||
|
|
||||||
|
if (isNaN(transactionId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid transaction ID'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
id: transactionId,
|
||||||
|
buyerId: userId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
offset: {
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
projectType: true,
|
||||||
|
location: true,
|
||||||
|
developer: {
|
||||||
|
select: {
|
||||||
|
fullName: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Transaction not found'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: transaction
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Transaction retrieval error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during transaction retrieval'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get marketplace statistics endpoint
|
||||||
|
router.get('/marketplace/stats', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Get marketplace statistics
|
||||||
|
const [
|
||||||
|
totalProjects,
|
||||||
|
verifiedProjects,
|
||||||
|
totalTransactions,
|
||||||
|
totalVolumeResult,
|
||||||
|
availableCreditsResult
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.carbonProject.count({
|
||||||
|
where: { isActive: true }
|
||||||
|
}),
|
||||||
|
prisma.carbonProject.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
verificationStatus: 'VERIFIED'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.transaction.count({
|
||||||
|
where: { status: TransactionStatus.CONFIRMED }
|
||||||
|
}),
|
||||||
|
prisma.transaction.aggregate({
|
||||||
|
where: { status: TransactionStatus.CONFIRMED },
|
||||||
|
_sum: { totalAmount: true }
|
||||||
|
}),
|
||||||
|
prisma.carbonProject.aggregate({
|
||||||
|
where: { isActive: true },
|
||||||
|
_sum: {
|
||||||
|
totalCreditsAvailable: true,
|
||||||
|
creditsSold: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCreditsAvailable = (availableCreditsResult._sum.totalCreditsAvailable || 0) -
|
||||||
|
(availableCreditsResult._sum.creditsSold || 0);
|
||||||
|
const totalVolumeTraded = totalVolumeResult._sum.totalAmount || 0;
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalProjects,
|
||||||
|
verifiedProjects,
|
||||||
|
totalCreditsAvailable,
|
||||||
|
totalTransactions,
|
||||||
|
totalVolumeTraded
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Marketplace stats error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during marketplace stats retrieval'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
119
src/routes/wallet.ts
Normal file
119
src/routes/wallet.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { authenticateToken } from '@/middleware/auth';
|
||||||
|
import { validate, walletLinkSchema } from '@/middleware/validation';
|
||||||
|
import { walletService } from '@/services/wallet';
|
||||||
|
import { WalletLinkRequest, ApiResponse } from '@/types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All wallet routes require authentication
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// Link wallet endpoint
|
||||||
|
router.post('/link', validate(walletLinkSchema), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { walletAddress }: WalletLinkRequest = req.body;
|
||||||
|
const userId = req.user!.id;
|
||||||
|
|
||||||
|
const result = await walletService.linkWallet(userId, walletAddress);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.message
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: result.message
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wallet link error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during wallet linking'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlink wallet endpoint
|
||||||
|
router.delete('/unlink', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
|
||||||
|
const result = await walletService.unlinkWallet(userId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.message
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: result.message
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wallet unlink error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during wallet unlinking'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get wallet info endpoint
|
||||||
|
router.get('/info', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
|
||||||
|
const result = await walletService.getWalletInfo(userId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.message
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wallet info error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during wallet info retrieval'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate test wallet endpoint
|
||||||
|
router.post('/generate-test-wallet', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = walletService.generateTestWallet();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: result.message
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test wallet generation error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error during test wallet generation'
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
214
src/server.ts
Normal file
214
src/server.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import Database from '@/utils/database';
|
||||||
|
import { setupSecurity } from '@/middleware/security';
|
||||||
|
import { errorHandler, notFoundHandler } from '@/middleware/error';
|
||||||
|
import { blockchainService } from '@/services/blockchain';
|
||||||
|
import { ApiResponse } from '@/types';
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
import authRoutes from '@/routes/auth';
|
||||||
|
import walletRoutes from '@/routes/wallet';
|
||||||
|
import projectRoutes from '@/routes/projects';
|
||||||
|
import tradingRoutes from '@/routes/trading';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
class Server {
|
||||||
|
private app: express.Application;
|
||||||
|
private port: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.app = express();
|
||||||
|
this.port = parseInt(process.env.PORT || '8000');
|
||||||
|
|
||||||
|
this.setupMiddleware();
|
||||||
|
this.setupRoutes();
|
||||||
|
this.setupErrorHandling();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMiddleware(): void {
|
||||||
|
// Security middleware
|
||||||
|
setupSecurity(this.app);
|
||||||
|
|
||||||
|
// Compression middleware
|
||||||
|
this.app.use(compression());
|
||||||
|
|
||||||
|
// Logging middleware
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
this.app.use(morgan('combined'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body parsing middleware
|
||||||
|
this.app.use(express.json({ limit: '10mb' }));
|
||||||
|
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRoutes(): void {
|
||||||
|
// Health check endpoint
|
||||||
|
this.app.get('/health', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Check database connection
|
||||||
|
const dbHealthy = await Database.healthCheck();
|
||||||
|
|
||||||
|
// Check blockchain connection (optional)
|
||||||
|
const blockchainHealthy = await blockchainService.isNetworkConnected();
|
||||||
|
|
||||||
|
// Check environment variables
|
||||||
|
const requiredEnvVars = ['JWT_SECRET'];
|
||||||
|
const missingEnvVars = requiredEnvVars.filter(env => !process.env[env]);
|
||||||
|
const envHealthy = missingEnvVars.length === 0;
|
||||||
|
|
||||||
|
const isHealthy = dbHealthy && envHealthy;
|
||||||
|
|
||||||
|
const healthData = {
|
||||||
|
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0.0',
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
services: {
|
||||||
|
database: dbHealthy ? 'healthy' : 'unhealthy',
|
||||||
|
blockchain: blockchainHealthy ? 'connected' : 'disconnected',
|
||||||
|
environment: envHealthy ? 'healthy' : `missing: ${missingEnvVars.join(', ')}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(isHealthy ? 200 : 503).json({
|
||||||
|
success: isHealthy,
|
||||||
|
data: healthData
|
||||||
|
} as ApiResponse);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Health check error:', error);
|
||||||
|
return res.status(503).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Health check failed',
|
||||||
|
data: {
|
||||||
|
status: 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Root endpoint with platform information
|
||||||
|
this.app.get('/', (req: Request, res: Response) => {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
title: 'Carbon Offset Trading Platform',
|
||||||
|
description: 'A blockchain-enabled platform for trading carbon offsets between project developers and buyers',
|
||||||
|
version: '1.0.0',
|
||||||
|
documentation: '/api/docs',
|
||||||
|
healthCheck: '/health',
|
||||||
|
endpoints: {
|
||||||
|
auth: '/api/auth',
|
||||||
|
wallet: '/api/wallet',
|
||||||
|
projects: '/api/projects',
|
||||||
|
trading: '/api/trading'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as ApiResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
this.app.use('/api/auth', authRoutes);
|
||||||
|
this.app.use('/api/wallet', walletRoutes);
|
||||||
|
this.app.use('/api/projects', projectRoutes);
|
||||||
|
this.app.use('/api/trading', tradingRoutes);
|
||||||
|
|
||||||
|
// API documentation placeholder
|
||||||
|
this.app.get('/api/docs', (req: Request, res: Response) => {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'API Documentation',
|
||||||
|
version: '1.0.0',
|
||||||
|
endpoints: {
|
||||||
|
'POST /api/auth/register': 'Register new user (developer or buyer)',
|
||||||
|
'POST /api/auth/login': 'User login',
|
||||||
|
'POST /api/wallet/link': 'Link blockchain wallet to user account',
|
||||||
|
'DELETE /api/wallet/unlink': 'Unlink wallet from user account',
|
||||||
|
'GET /api/wallet/info': 'Get wallet information and balance',
|
||||||
|
'POST /api/wallet/generate-test-wallet': 'Generate test wallet for development',
|
||||||
|
'POST /api/projects': 'Create new carbon offset project (developers only)',
|
||||||
|
'GET /api/projects': 'Browse all available projects',
|
||||||
|
'GET /api/projects/my-projects': 'Get developer\'s projects',
|
||||||
|
'GET /api/projects/:id': 'Get project details',
|
||||||
|
'PUT /api/projects/:id': 'Update project (own projects only)',
|
||||||
|
'DELETE /api/projects/:id': 'Delete project (own projects only)',
|
||||||
|
'POST /api/trading/purchase': 'Purchase carbon offsets (buyers only)',
|
||||||
|
'GET /api/trading/my-transactions': 'Get user\'s transactions',
|
||||||
|
'GET /api/trading/transactions/:id': 'Get transaction details',
|
||||||
|
'GET /api/trading/marketplace/stats': 'Get marketplace statistics'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as ApiResponse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupErrorHandling(): void {
|
||||||
|
// 404 handler
|
||||||
|
this.app.use(notFoundHandler);
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
this.app.use(errorHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Connect to database
|
||||||
|
await Database.connect();
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
this.app.listen(this.port, '0.0.0.0', () => {
|
||||||
|
console.log('🚀 Carbon Offset Trading Platform Started');
|
||||||
|
console.log(`📍 Server running on http://0.0.0.0:${this.port}`);
|
||||||
|
console.log(`🏥 Health check: http://0.0.0.0:${this.port}/health`);
|
||||||
|
console.log(`📚 API docs: http://0.0.0.0:${this.port}/api/docs`);
|
||||||
|
console.log(`🌍 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
this.setupGracefulShutdown();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGracefulShutdown(): void {
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
console.log(`\n🛑 Received ${signal}. Starting graceful shutdown...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Database.disconnect();
|
||||||
|
console.log('✅ Database disconnected');
|
||||||
|
console.log('✅ Server shut down gracefully');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error during shutdown:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server if this file is run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
const server = new Server();
|
||||||
|
server.start().catch(error => {
|
||||||
|
console.error('❌ Failed to start application:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Server;
|
188
src/services/blockchain.ts
Normal file
188
src/services/blockchain.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import Web3 from 'web3';
|
||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { BlockchainTransaction, WalletInfo } from '@/types';
|
||||||
|
|
||||||
|
export class BlockchainService {
|
||||||
|
private web3: Web3;
|
||||||
|
private provider: ethers.JsonRpcProvider;
|
||||||
|
private contractAbi: any[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const rpcUrl = process.env.BLOCKCHAIN_RPC_URL || 'http://localhost:8545';
|
||||||
|
this.web3 = new Web3(rpcUrl);
|
||||||
|
this.provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||||
|
this.contractAbi = this.getCarbonTokenAbi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCarbonTokenAbi(): any[] {
|
||||||
|
// Simplified ABI for a carbon credit token contract
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
inputs: [
|
||||||
|
{ name: 'to', type: 'address' },
|
||||||
|
{ name: 'tokenId', type: 'uint256' },
|
||||||
|
{ name: 'credits', type: 'uint256' }
|
||||||
|
],
|
||||||
|
name: 'mintCarbonCredit',
|
||||||
|
outputs: [{ name: '', type: 'bool' }],
|
||||||
|
type: 'function'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [
|
||||||
|
{ name: 'from', type: 'address' },
|
||||||
|
{ name: 'to', type: 'address' },
|
||||||
|
{ name: 'tokenId', type: 'uint256' }
|
||||||
|
],
|
||||||
|
name: 'transferFrom',
|
||||||
|
outputs: [{ name: '', type: 'bool' }],
|
||||||
|
type: 'function'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [{ name: 'tokenId', type: 'uint256' }],
|
||||||
|
name: 'ownerOf',
|
||||||
|
outputs: [{ name: '', type: 'address' }],
|
||||||
|
type: 'function'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateWalletAddress(address: string): boolean {
|
||||||
|
try {
|
||||||
|
return this.web3.utils.isAddress(address);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating wallet address:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateWallet(): WalletInfo {
|
||||||
|
try {
|
||||||
|
const wallet = ethers.Wallet.createRandom();
|
||||||
|
return {
|
||||||
|
address: wallet.address,
|
||||||
|
privateKey: wallet.privateKey,
|
||||||
|
publicKey: wallet.publicKey
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating wallet:', error);
|
||||||
|
throw new Error('Failed to generate wallet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWalletBalance(address: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
if (!this.validateWalletAddress(address)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceWei = await this.web3.eth.getBalance(address);
|
||||||
|
const balanceEth = this.web3.utils.fromWei(balanceWei, 'ether');
|
||||||
|
return parseFloat(balanceEth);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting balance for ${address}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createCarbonTokenTransaction(
|
||||||
|
contractAddress: string,
|
||||||
|
fromAddress: string,
|
||||||
|
toAddress: string,
|
||||||
|
tokenId: number
|
||||||
|
): Promise<BlockchainTransaction | null> {
|
||||||
|
try {
|
||||||
|
if (!this.validateWalletAddress(contractAddress) ||
|
||||||
|
!this.validateWalletAddress(fromAddress) ||
|
||||||
|
!this.validateWalletAddress(toAddress)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = new this.web3.eth.Contract(this.contractAbi, contractAddress);
|
||||||
|
const data = contract.methods.transferFrom(fromAddress, toAddress, tokenId).encodeABI();
|
||||||
|
|
||||||
|
const gasPrice = await this.web3.eth.getGasPrice();
|
||||||
|
const gasLimit = await contract.methods.transferFrom(fromAddress, toAddress, tokenId)
|
||||||
|
.estimateGas({ from: fromAddress });
|
||||||
|
|
||||||
|
return {
|
||||||
|
to: contractAddress,
|
||||||
|
value: '0',
|
||||||
|
data,
|
||||||
|
gasLimit: gasLimit.toString(),
|
||||||
|
gasPrice: gasPrice.toString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating transaction:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async signAndSendTransaction(
|
||||||
|
transaction: BlockchainTransaction,
|
||||||
|
privateKey: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const wallet = new ethers.Wallet(privateKey, this.provider);
|
||||||
|
const tx = await wallet.sendTransaction({
|
||||||
|
to: transaction.to,
|
||||||
|
value: transaction.value,
|
||||||
|
data: transaction.data,
|
||||||
|
gasLimit: transaction.gasLimit,
|
||||||
|
gasPrice: transaction.gasPrice
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.hash;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error signing/sending transaction:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTransactionReceipt(txHash: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const receipt = await this.web3.eth.getTransactionReceipt(txHash);
|
||||||
|
return {
|
||||||
|
transactionHash: receipt.transactionHash,
|
||||||
|
blockNumber: receipt.blockNumber,
|
||||||
|
gasUsed: receipt.gasUsed,
|
||||||
|
status: receipt.status
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting transaction receipt:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async verifyTokenOwnership(
|
||||||
|
contractAddress: string,
|
||||||
|
tokenId: number,
|
||||||
|
ownerAddress: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!this.validateWalletAddress(contractAddress) ||
|
||||||
|
!this.validateWalletAddress(ownerAddress)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = new this.web3.eth.Contract(this.contractAbi, contractAddress);
|
||||||
|
const actualOwner = await contract.methods.ownerOf(tokenId).call();
|
||||||
|
|
||||||
|
return actualOwner.toLowerCase() === ownerAddress.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying ownership:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isNetworkConnected(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.web3.eth.getBlockNumber();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Blockchain network connection failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blockchainService = new BlockchainService();
|
179
src/services/wallet.ts
Normal file
179
src/services/wallet.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { prisma } from '@/utils/database';
|
||||||
|
import { blockchainService } from './blockchain';
|
||||||
|
import { WalletResponse, WalletInfo } from '@/types';
|
||||||
|
|
||||||
|
export class WalletService {
|
||||||
|
public async linkWallet(userId: number, walletAddress: string): Promise<WalletResponse> {
|
||||||
|
try {
|
||||||
|
// Validate wallet address format
|
||||||
|
if (!blockchainService.validateWalletAddress(walletAddress)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: 'Invalid wallet address format'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if wallet is already linked to another user
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
walletAddress,
|
||||||
|
id: { not: userId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: 'Wallet address is already linked to another account'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: 'User not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user wallet information
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
walletAddress,
|
||||||
|
walletPublicKey: walletAddress // In Ethereum, address is derived from public key
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
walletLinked: true,
|
||||||
|
walletAddress,
|
||||||
|
message: 'Wallet linked successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error linking wallet:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: `Database error: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unlinkWallet(userId: number): Promise<WalletResponse> {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: 'User not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.walletAddress) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: 'No wallet linked to this account'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear wallet information
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
walletAddress: null,
|
||||||
|
walletPublicKey: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
walletLinked: false,
|
||||||
|
message: 'Wallet unlinked successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unlinking wallet:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: `Database error: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWalletInfo(userId: number): Promise<WalletResponse> {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: 'User not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.walletAddress) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
walletLinked: false,
|
||||||
|
walletAddress: undefined,
|
||||||
|
balance: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get wallet balance from blockchain
|
||||||
|
const balance = await blockchainService.getWalletBalance(user.walletAddress);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
walletLinked: true,
|
||||||
|
walletAddress: user.walletAddress,
|
||||||
|
balance: balance || undefined
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting wallet info:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
walletLinked: false,
|
||||||
|
message: `Error retrieving wallet information: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateTestWallet(): { success: boolean; message: string; walletData?: WalletInfo; warning?: string } {
|
||||||
|
try {
|
||||||
|
const walletData = blockchainService.generateWallet();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Test wallet generated successfully',
|
||||||
|
walletData,
|
||||||
|
warning: 'This is for testing only. Keep private key secure!'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating test wallet:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to generate test wallet: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletService = new WalletService();
|
184
src/types/index.ts
Normal file
184
src/types/index.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { UserType, VerificationStatus, OffsetStatus, TransactionStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
// User Types
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
fullName: string;
|
||||||
|
userType: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
userType: UserType;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
walletAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
userType: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wallet Types
|
||||||
|
export interface WalletLinkRequest {
|
||||||
|
walletAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletResponse {
|
||||||
|
success: boolean;
|
||||||
|
walletLinked: boolean;
|
||||||
|
walletAddress?: string;
|
||||||
|
balance?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carbon Project Types
|
||||||
|
export interface CreateProjectRequest {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
projectType: string;
|
||||||
|
methodology: string;
|
||||||
|
totalCreditsAvailable: number;
|
||||||
|
pricePerCredit: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectRequest {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
projectType?: string;
|
||||||
|
methodology?: string;
|
||||||
|
totalCreditsAvailable?: number;
|
||||||
|
pricePerCredit?: number;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
verificationDocumentUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectResponse {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
projectType: string;
|
||||||
|
methodology: string;
|
||||||
|
totalCreditsAvailable: number;
|
||||||
|
creditsSold: number;
|
||||||
|
pricePerCredit: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
verificationStatus: VerificationStatus;
|
||||||
|
verificationDocumentUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
contractAddress?: string;
|
||||||
|
tokenId?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
developerId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectListResponse {
|
||||||
|
projects: ProjectResponse[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction Types
|
||||||
|
export interface PurchaseRequest {
|
||||||
|
projectId: number;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionResponse {
|
||||||
|
id: number;
|
||||||
|
transactionHash: string;
|
||||||
|
quantity: number;
|
||||||
|
pricePerCredit: number;
|
||||||
|
totalAmount: number;
|
||||||
|
status: TransactionStatus;
|
||||||
|
blockNumber?: number;
|
||||||
|
gasUsed?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
confirmedAt?: Date;
|
||||||
|
buyerId: number;
|
||||||
|
offsetId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionListResponse {
|
||||||
|
transactions: TransactionResponse[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketplace Types
|
||||||
|
export interface MarketplaceStats {
|
||||||
|
totalProjects: number;
|
||||||
|
verifiedProjects: number;
|
||||||
|
totalCreditsAvailable: number;
|
||||||
|
totalTransactions: number;
|
||||||
|
totalVolumeTraded: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockchain Types
|
||||||
|
export interface BlockchainTransaction {
|
||||||
|
to: string;
|
||||||
|
value: string;
|
||||||
|
data: string;
|
||||||
|
gasLimit: string;
|
||||||
|
gasPrice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInfo {
|
||||||
|
address: string;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response Types
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationOptions {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOptions {
|
||||||
|
projectType?: string;
|
||||||
|
verificationStatus?: VerificationStatus;
|
||||||
|
status?: TransactionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express Request Extensions
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
userType: UserType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { UserType, VerificationStatus, OffsetStatus, TransactionStatus };
|
89
src/utils/auth.ts
Normal file
89
src/utils/auth.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { JwtPayload, UserType } from '@/types';
|
||||||
|
|
||||||
|
export class AuthUtils {
|
||||||
|
private static readonly SALT_ROUNDS = 12;
|
||||||
|
private static readonly JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||||
|
private static readonly JWT_EXPIRES_IN = '24h';
|
||||||
|
|
||||||
|
public static async hashPassword(password: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await bcrypt.hash(password, this.SALT_ROUNDS);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error hashing password:', error);
|
||||||
|
throw new Error('Failed to hash password');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await bcrypt.compare(password, hashedPassword);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying password:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static generateAccessToken(payload: JwtPayload): string {
|
||||||
|
try {
|
||||||
|
return jwt.sign(payload, this.JWT_SECRET, {
|
||||||
|
expiresIn: this.JWT_EXPIRES_IN,
|
||||||
|
issuer: 'carbon-offset-platform',
|
||||||
|
audience: 'carbon-offset-users'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating access token:', error);
|
||||||
|
throw new Error('Failed to generate access token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static verifyAccessToken(token: string): JwtPayload | null {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, this.JWT_SECRET, {
|
||||||
|
issuer: 'carbon-offset-platform',
|
||||||
|
audience: 'carbon-offset-users'
|
||||||
|
}) as JwtPayload;
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
console.warn('JWT token expired');
|
||||||
|
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
console.warn('Invalid JWT token');
|
||||||
|
} else {
|
||||||
|
console.error('Error verifying access token:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static validateEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static validatePassword(password: string): { isValid: boolean; message?: string } {
|
||||||
|
if (password.length < 8) {
|
||||||
|
return { isValid: false, message: 'Password must be at least 8 characters long' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/(?=.*[a-z])/.test(password)) {
|
||||||
|
return { isValid: false, message: 'Password must contain at least one lowercase letter' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/(?=.*[A-Z])/.test(password)) {
|
||||||
|
return { isValid: false, message: 'Password must contain at least one uppercase letter' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/(?=.*\d)/.test(password)) {
|
||||||
|
return { isValid: false, message: 'Password must contain at least one number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static validateUserType(userType: string): boolean {
|
||||||
|
return Object.values(UserType).includes(userType as UserType);
|
||||||
|
}
|
||||||
|
}
|
47
src/utils/database.ts
Normal file
47
src/utils/database.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
class Database {
|
||||||
|
private static instance: PrismaClient;
|
||||||
|
|
||||||
|
public static getInstance(): PrismaClient {
|
||||||
|
if (!Database.instance) {
|
||||||
|
Database.instance = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Database.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async connect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Database.getInstance().$connect();
|
||||||
|
console.log('📦 Database connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database connection failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Database.getInstance().$disconnect();
|
||||||
|
console.log('📦 Database disconnected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database disconnection failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Database.getInstance().$queryRaw`SELECT 1`;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database health check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = Database.getInstance();
|
||||||
|
export default Database;
|
44
tsconfig.json
Normal file
44
tsconfig.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"],
|
||||||
|
"@/types/*": ["./types/*"],
|
||||||
|
"@/models/*": ["./models/*"],
|
||||||
|
"@/services/*": ["./services/*"],
|
||||||
|
"@/routes/*": ["./routes/*"],
|
||||||
|
"@/middleware/*": ["./middleware/*"],
|
||||||
|
"@/utils/*": ["./utils/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user