From 3ef47ed096f9a61ebda2cd562daf9d42783ffbf2 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Fri, 20 Jun 2025 17:14:37 +0000 Subject: [PATCH] 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 --- .env.example | 18 ++ .eslintrc.js | 28 ++ .gitignore | 230 ++++----------- README.md | 198 ++++++++----- alembic.ini | 97 ------- alembic/env.py | 87 ------ alembic/script.py.mako | 24 -- alembic/versions/001_initial_migration.py | 118 -------- app/__init__.py | 1 - app/api/__init__.py | 1 - app/api/auth.py | 60 ---- app/api/projects.py | 175 ------------ app/api/trading.py | 215 -------------- app/api/wallet.py | 56 ---- app/core/__init__.py | 1 - app/core/deps.py | 57 ---- app/core/security.py | 39 --- app/db/base.py | 3 - app/db/session.py | 24 -- app/models/__init__.py | 6 - app/models/carbon_offset.py | 29 -- app/models/carbon_project.py | 44 --- app/models/transaction.py | 33 --- app/models/user.py | 25 -- app/schemas/__init__.py | 1 - app/schemas/carbon_project.py | 50 ---- app/schemas/transaction.py | 34 --- app/schemas/user.py | 38 --- app/services/__init__.py | 1 - app/services/blockchain.py | 168 ----------- app/services/wallet.py | 130 --------- main.py | 88 ------ openapi.json | 59 ---- package.json | 66 +++++ prisma/schema.prisma | 143 ++++++++++ requirements.txt | 13 - src/middleware/auth.ts | 76 +++++ src/middleware/error.ts | 46 +++ src/middleware/security.ts | 60 ++++ src/middleware/validation.ts | 186 ++++++++++++ src/routes/auth.ts | 131 +++++++++ src/routes/projects.ts | 292 +++++++++++++++++++ src/routes/trading.ts | 329 ++++++++++++++++++++++ src/routes/wallet.ts | 119 ++++++++ src/server.ts | 214 ++++++++++++++ src/services/blockchain.ts | 188 +++++++++++++ src/services/wallet.ts | 179 ++++++++++++ src/types/index.ts | 184 ++++++++++++ src/utils/auth.ts | 89 ++++++ src/utils/database.ts | 47 ++++ tsconfig.json | 44 +++ 51 files changed, 2608 insertions(+), 1936 deletions(-) create mode 100644 .env.example create mode 100644 .eslintrc.js delete mode 100644 alembic.ini delete mode 100644 alembic/env.py delete mode 100644 alembic/script.py.mako delete mode 100644 alembic/versions/001_initial_migration.py delete mode 100644 app/__init__.py delete mode 100644 app/api/__init__.py delete mode 100644 app/api/auth.py delete mode 100644 app/api/projects.py delete mode 100644 app/api/trading.py delete mode 100644 app/api/wallet.py delete mode 100644 app/core/__init__.py delete mode 100644 app/core/deps.py delete mode 100644 app/core/security.py delete mode 100644 app/db/base.py delete mode 100644 app/db/session.py delete mode 100644 app/models/__init__.py delete mode 100644 app/models/carbon_offset.py delete mode 100644 app/models/carbon_project.py delete mode 100644 app/models/transaction.py delete mode 100644 app/models/user.py delete mode 100644 app/schemas/__init__.py delete mode 100644 app/schemas/carbon_project.py delete mode 100644 app/schemas/transaction.py delete mode 100644 app/schemas/user.py delete mode 100644 app/services/__init__.py delete mode 100644 app/services/blockchain.py delete mode 100644 app/services/wallet.py delete mode 100644 main.py delete mode 100644 openapi.json create mode 100644 package.json create mode 100644 prisma/schema.prisma delete mode 100644 requirements.txt create mode 100644 src/middleware/auth.ts create mode 100644 src/middleware/error.ts create mode 100644 src/middleware/security.ts create mode 100644 src/middleware/validation.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/projects.ts create mode 100644 src/routes/trading.ts create mode 100644 src/routes/wallet.ts create mode 100644 src/server.ts create mode 100644 src/services/blockchain.ts create mode 100644 src/services/wallet.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/auth.ts create mode 100644 src/utils/database.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..25c9a44 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..1f0c6e9 --- /dev/null +++ b/.eslintrc.js @@ -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'], +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2f9c812..94464f0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md index 1a641e0..12459df 100644 --- a/README.md +++ b/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. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index c8dd76c..0000000 --- a/alembic.ini +++ /dev/null @@ -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 \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index 4e65d3e..0000000 --- a/alembic/env.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index 37d0cac..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py deleted file mode 100644 index b62c0ab..0000000 --- a/alembic/versions/001_initial_migration.py +++ /dev/null @@ -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') \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index a0aeb0c..0000000 --- a/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Carbon Offset Trading Platform \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py deleted file mode 100644 index 03eb80b..0000000 --- a/app/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# API modules \ No newline at end of file diff --git a/app/api/auth.py b/app/api/auth.py deleted file mode 100644 index 2ad2690..0000000 --- a/app/api/auth.py +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/app/api/projects.py b/app/api/projects.py deleted file mode 100644 index 1388398..0000000 --- a/app/api/projects.py +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/app/api/trading.py b/app/api/trading.py deleted file mode 100644 index 298fd0c..0000000 --- a/app/api/trading.py +++ /dev/null @@ -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 - } \ No newline at end of file diff --git a/app/api/wallet.py b/app/api/wallet.py deleted file mode 100644 index 3c7e485..0000000 --- a/app/api/wallet.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py deleted file mode 100644 index eff66b2..0000000 --- a/app/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Core modules \ No newline at end of file diff --git a/app/core/deps.py b/app/core/deps.py deleted file mode 100644 index 55b98a5..0000000 --- a/app/core/deps.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py deleted file mode 100644 index 1ec4606..0000000 --- a/app/core/security.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py deleted file mode 100644 index 7c2377a..0000000 --- a/app/db/base.py +++ /dev/null @@ -1,3 +0,0 @@ -from sqlalchemy.ext.declarative import declarative_base - -Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py deleted file mode 100644 index 3121200..0000000 --- a/app/db/session.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py deleted file mode 100644 index 169c765..0000000 --- a/app/models/__init__.py +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/app/models/carbon_offset.py b/app/models/carbon_offset.py deleted file mode 100644 index 600bbfa..0000000 --- a/app/models/carbon_offset.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/app/models/carbon_project.py b/app/models/carbon_project.py deleted file mode 100644 index 866fec0..0000000 --- a/app/models/carbon_project.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py deleted file mode 100644 index 968ce9e..0000000 --- a/app/models/transaction.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py deleted file mode 100644 index 29d89a9..0000000 --- a/app/models/user.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py deleted file mode 100644 index 715ed22..0000000 --- a/app/schemas/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Schema modules \ No newline at end of file diff --git a/app/schemas/carbon_project.py b/app/schemas/carbon_project.py deleted file mode 100644 index 9ea6fb5..0000000 --- a/app/schemas/carbon_project.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/schemas/transaction.py b/app/schemas/transaction.py deleted file mode 100644 index e4560f9..0000000 --- a/app/schemas/transaction.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py deleted file mode 100644 index 7b3ac66..0000000 --- a/app/schemas/user.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py deleted file mode 100644 index bfdb611..0000000 --- a/app/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Service modules \ No newline at end of file diff --git a/app/services/blockchain.py b/app/services/blockchain.py deleted file mode 100644 index eb50444..0000000 --- a/app/services/blockchain.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/app/services/wallet.py b/app/services/wallet.py deleted file mode 100644 index 570a5ae..0000000 --- a/app/services/wallet.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 6311d8e..0000000 --- a/main.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/openapi.json b/openapi.json deleted file mode 100644 index 7345577..0000000 --- a/openapi.json +++ /dev/null @@ -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"} - } - } - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..44d9acc --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..2f07f8a --- /dev/null +++ b/prisma/schema.prisma @@ -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 +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index af1c3db..0000000 --- a/requirements.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..901ba54 --- /dev/null +++ b/src/middleware/auth.ts @@ -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); \ No newline at end of file diff --git a/src/middleware/error.ts b/src/middleware/error.ts new file mode 100644 index 0000000..c38a4ca --- /dev/null +++ b/src/middleware/error.ts @@ -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); +}; \ No newline at end of file diff --git a/src/middleware/security.ts b/src/middleware/security.ts new file mode 100644 index 0000000..bb34979 --- /dev/null +++ b/src/middleware/security.ts @@ -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); +}; \ No newline at end of file diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts new file mode 100644 index 0000000..752706c --- /dev/null +++ b/src/middleware/validation.ts @@ -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(); + }; +}; \ No newline at end of file diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..b995c2d --- /dev/null +++ b/src/routes/auth.ts @@ -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); + + } 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; \ No newline at end of file diff --git a/src/routes/projects.ts b/src/routes/projects.ts new file mode 100644 index 0000000..7bd31ab --- /dev/null +++ b/src/routes/projects.ts @@ -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; \ No newline at end of file diff --git a/src/routes/trading.ts b/src/routes/trading.ts new file mode 100644 index 0000000..fcbc7d0 --- /dev/null +++ b/src/routes/trading.ts @@ -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; \ No newline at end of file diff --git a/src/routes/wallet.ts b/src/routes/wallet.ts new file mode 100644 index 0000000..4848573 --- /dev/null +++ b/src/routes/wallet.ts @@ -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; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..95713be --- /dev/null +++ b/src/server.ts @@ -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 { + 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; \ No newline at end of file diff --git a/src/services/blockchain.ts b/src/services/blockchain.ts new file mode 100644 index 0000000..2c692a7 --- /dev/null +++ b/src/services/blockchain.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await this.web3.eth.getBlockNumber(); + return true; + } catch (error) { + console.error('Blockchain network connection failed:', error); + return false; + } + } +} + +export const blockchainService = new BlockchainService(); \ No newline at end of file diff --git a/src/services/wallet.ts b/src/services/wallet.ts new file mode 100644 index 0000000..107b512 --- /dev/null +++ b/src/services/wallet.ts @@ -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 { + 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 { + 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 { + 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(); \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..5d91e6c --- /dev/null +++ b/src/types/index.ts @@ -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 { + 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 }; \ No newline at end of file diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..4406c65 --- /dev/null +++ b/src/utils/auth.ts @@ -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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/src/utils/database.ts b/src/utils/database.ts new file mode 100644 index 0000000..f457f65 --- /dev/null +++ b/src/utils/database.ts @@ -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 { + 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 { + 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 { + 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; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..79ac088 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} \ No newline at end of file