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*
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
media/
|
||||
*.db
|
||||
whitelist.txt
|
||||
ai_docs/
|
||||
specs/
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
test_cases.py
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
# Production builds
|
||||
dist/
|
||||
downloads/
|
||||
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
|
||||
build/
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
test_case1.py
|
||||
pip-delete-this-directory.txt
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.test
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.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
|
||||
# Database
|
||||
storage/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# PyBuilder
|
||||
.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/
|
||||
# IDE files
|
||||
.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/
|
||||
.dump.rdb
|
||||
.celery.log
|
||||
docker-compose.yaml
|
||||
# project analysis result
|
||||
analysis_results.json
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
**/.claude/settings.local.json
|
||||
*.aider
|
||||
.claude/
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.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
|
||||
|
||||
- **Backend**: FastAPI (Python)
|
||||
- **Database**: SQLite with SQLAlchemy ORM
|
||||
- **Backend**: Node.js with Express.js and TypeScript
|
||||
- **Database**: SQLite with Prisma ORM
|
||||
- **Authentication**: JWT tokens with bcrypt password hashing
|
||||
- **Blockchain**: Web3.py for Ethereum integration
|
||||
- **Database Migrations**: Alembic
|
||||
- **API Documentation**: OpenAPI/Swagger
|
||||
- **Blockchain**: Web3.js and Ethers.js for Ethereum integration
|
||||
- **Validation**: Joi for request validation
|
||||
- **Security**: Helmet, CORS, Rate limiting
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── main.py # FastAPI application entry point
|
||||
├── requirements.txt # Python dependencies
|
||||
├── alembic.ini # Database migration configuration
|
||||
├── openapi.json # API specification
|
||||
├── app/
|
||||
│ ├── api/ # API endpoints
|
||||
│ │ ├── auth.py # Authentication endpoints
|
||||
│ │ ├── wallet.py # Wallet management endpoints
|
||||
│ │ ├── projects.py # Project management endpoints
|
||||
│ │ └── trading.py # Trading and marketplace endpoints
|
||||
│ ├── core/ # Core functionality
|
||||
│ │ ├── security.py # Authentication and security
|
||||
│ │ └── deps.py # Dependency injection
|
||||
│ ├── db/ # Database configuration
|
||||
│ │ ├── base.py # SQLAlchemy base
|
||||
│ │ └── session.py # Database session management
|
||||
│ ├── models/ # Database models
|
||||
│ │ ├── user.py # User model
|
||||
│ │ ├── carbon_project.py # Carbon project model
|
||||
│ │ ├── carbon_offset.py # Carbon offset model
|
||||
│ │ └── transaction.py # Transaction model
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── user.py # User schemas
|
||||
│ │ ├── carbon_project.py # Project schemas
|
||||
│ │ └── transaction.py # Transaction schemas
|
||||
│ └── services/ # Business logic services
|
||||
│ ├── blockchain.py # Blockchain integration
|
||||
│ └── wallet.py # Wallet management
|
||||
└── alembic/ # Database migrations
|
||||
└── versions/ # Migration files
|
||||
├── src/
|
||||
│ ├── server.ts # Express application entry point
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ │ └── index.ts # Main types and interfaces
|
||||
│ ├── routes/ # API endpoints
|
||||
│ │ ├── auth.ts # Authentication endpoints
|
||||
│ │ ├── wallet.ts # Wallet management endpoints
|
||||
│ │ ├── projects.ts # Project management endpoints
|
||||
│ │ └── trading.ts # Trading and marketplace endpoints
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ │ ├── auth.ts # Authentication middleware
|
||||
│ │ ├── validation.ts # Request validation middleware
|
||||
│ │ ├── security.ts # Security middleware
|
||||
│ │ └── error.ts # Error handling middleware
|
||||
│ ├── services/ # Business logic services
|
||||
│ │ ├── blockchain.ts # Blockchain integration
|
||||
│ │ └── wallet.ts # Wallet management
|
||||
│ └── utils/ # Utility functions
|
||||
│ ├── database.ts # Database connection and utilities
|
||||
│ └── auth.ts # Authentication utilities
|
||||
├── prisma/
|
||||
│ └── schema.prisma # Database schema
|
||||
├── storage/ # Application storage directory
|
||||
│ └── db/ # SQLite database files
|
||||
├── package.json # Node.js dependencies and scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
└── .env.example # Environment variables template
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Run database migrations:
|
||||
2. Set up environment variables:
|
||||
```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
|
||||
export SECRET_KEY="your-secret-key-here"
|
||||
export BLOCKCHAIN_RPC_URL="https://your-ethereum-rpc-url" # Optional, defaults to localhost
|
||||
npm run db:generate
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
Start the development server:
|
||||
### Development
|
||||
Start the development server with hot reload:
|
||||
```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:
|
||||
- **API**: http://localhost:8000
|
||||
- **Documentation**: http://localhost:8000/docs
|
||||
- **Alternative Docs**: http://localhost:8000/redoc
|
||||
- **Health Check**: http://localhost:8000/health
|
||||
- **API Documentation**: http://localhost:8000/api/docs
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| 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 |
|
||||
| `CORS_ORIGIN` | CORS allowed origins | No | * |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/register` - Register new user (developer or buyer)
|
||||
- `POST /auth/login` - User login
|
||||
- `POST /api/auth/register` - Register new user (developer or buyer)
|
||||
- `POST /api/auth/login` - User login
|
||||
|
||||
### Wallet Management
|
||||
- `POST /wallet/link` - Link blockchain wallet to user account
|
||||
- `DELETE /wallet/unlink` - Unlink wallet from user account
|
||||
- `GET /wallet/info` - Get wallet information and balance
|
||||
- `POST /wallet/generate-test-wallet` - Generate test wallet for development
|
||||
- `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
|
||||
|
||||
### Project Management (Developers)
|
||||
- `POST /projects/` - Create new carbon offset project
|
||||
- `GET /projects/my-projects` - Get developer's projects
|
||||
- `PUT /projects/{project_id}` - Update project
|
||||
- `DELETE /projects/{project_id}` - Delete project
|
||||
- `POST /api/projects/` - Create new carbon offset project
|
||||
- `GET /api/projects/my-projects` - Get developer's projects
|
||||
- `PUT /api/projects/{project_id}` - Update project
|
||||
- `DELETE /api/projects/{project_id}` - Delete project
|
||||
|
||||
### Marketplace & Trading
|
||||
- `GET /projects/` - Browse all available projects
|
||||
- `GET /projects/{project_id}` - Get project details
|
||||
- `POST /trading/purchase` - Purchase carbon offsets
|
||||
- `GET /trading/my-transactions` - Get user's transactions
|
||||
- `GET /trading/marketplace` - Get marketplace statistics
|
||||
- `GET /api/projects/` - Browse all available projects
|
||||
- `GET /api/projects/{project_id}` - Get project details
|
||||
- `POST /api/trading/purchase` - Purchase carbon offsets
|
||||
- `GET /api/trading/my-transactions` - Get user's transactions
|
||||
- `GET /api/trading/marketplace/stats` - Get marketplace statistics
|
||||
|
||||
### System
|
||||
- `GET /` - Platform information
|
||||
@ -140,7 +149,7 @@ The application will be available at:
|
||||
### Users
|
||||
- User authentication and profile information
|
||||
- Wallet linking for blockchain integration
|
||||
- User types: "developer" or "buyer"
|
||||
- User types: DEVELOPER or BUYER
|
||||
|
||||
### Carbon Projects
|
||||
- Project details and metadata
|
||||
@ -163,39 +172,76 @@ The application will be available at:
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Create a new migration:
|
||||
Generate Prisma client:
|
||||
```bash
|
||||
alembic revision --autogenerate -m "Description of changes"
|
||||
npm run db:generate
|
||||
```
|
||||
|
||||
Apply migrations:
|
||||
Create and apply migrations:
|
||||
```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
|
||||
|
||||
Use the test wallet generation endpoint to create wallets for development:
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/wallet/generate-test-wallet
|
||||
curl -X POST http://localhost:8000/api/wallet/generate-test-wallet
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
- JWT-based authentication
|
||||
- Password hashing with bcrypt
|
||||
- Role-based access control (developer/buyer)
|
||||
- JWT-based authentication with secure token generation
|
||||
- Password hashing with bcrypt (12 rounds)
|
||||
- Role-based access control (DEVELOPER/BUYER)
|
||||
- Rate limiting and CORS protection
|
||||
- Helmet security headers
|
||||
- Input validation with Joi
|
||||
- Blockchain wallet 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
|
||||
|
||||
1. Follow the existing code structure and patterns
|
||||
2. Use type hints for all functions and methods
|
||||
3. Add appropriate error handling and validation
|
||||
4. Update documentation for any API changes
|
||||
5. Test wallet integration thoroughly
|
||||
1. Follow TypeScript best practices
|
||||
2. Use the existing code structure and patterns
|
||||
3. Add comprehensive input validation
|
||||
4. Include proper error handling
|
||||
5. Update documentation for API changes
|
||||
6. Test blockchain integration thoroughly
|
||||
7. Follow the established commit message format
|
||||
|
||||
## 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