diff --git a/README.md b/README.md index e8acfba..6408ddb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,233 @@ -# FastAPI Application +# Landing Page Backend API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A comprehensive FastAPI backend for a modern landing page with authentication, payments, testimonials, and communication features. + +## Features + +### 🔐 Authentication +- Email/password registration and login +- OAuth integration (Google, GitHub, Apple) +- JWT token-based authentication +- User profile management + +### 💳 Payment Processing +- Stripe integration for subscriptions +- Multiple pricing plans (Starter, Professional, Business, Enterprise) +- Webhook handling for subscription events +- Customer management + +### 📝 Content Management +- Testimonials API with featured/active filtering +- Usage statistics tracking and display +- Real-time metrics for landing page + +### 📧 Communication +- Email services via SendGrid +- Newsletter subscription handling +- Contact form processing +- Sales inquiry management +- Support chat configuration + +### 🏥 Health & Monitoring +- Health check endpoint +- Database connectivity monitoring +- API documentation (OpenAPI/Swagger) + +## Project Structure + +``` +├── app/ +│ ├── api/v1/ # API endpoints +│ │ ├── auth.py # Authentication routes +│ │ ├── testimonials.py # Testimonials CRUD +│ │ ├── usage_stats.py # Usage statistics +│ │ ├── communication.py # Email & contact forms +│ │ └── payments.py # Stripe payment handling +│ ├── auth/ # OAuth configuration +│ ├── core/ # Core settings and security +│ ├── db/ # Database configuration +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ └── services/ # Business logic services +├── alembic/ # Database migrations +├── main.py # FastAPI application +└── requirements.txt # Python dependencies +``` + +## Environment Variables + +Create a `.env` file in the root directory with the following variables: + +### Required +```bash +SECRET_KEY=your-secret-key-here +SENDGRID_API_KEY=your-sendgrid-api-key +FROM_EMAIL=noreply@yourdomain.com + +# Stripe +STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# OAuth - Google +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret + +# OAuth - GitHub +GITHUB_CLIENT_ID=your-github-client-id +GITHUB_CLIENT_SECRET=your-github-client-secret + +# OAuth - Apple +APPLE_CLIENT_ID=your-apple-client-id +APPLE_TEAM_ID=your-apple-team-id +APPLE_KEY_ID=your-apple-key-id +APPLE_PRIVATE_KEY=your-apple-private-key +``` + +### Optional +```bash +FRONTEND_URL=http://localhost:3000 +ADMIN_EMAIL=admin@yourdomain.com +SALES_EMAIL=sales@yourdomain.com +``` + +## Installation & Setup + +1. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +2. **Set up environment variables** + ```bash + cp .env.example .env + # Edit .env with your actual values + ``` + +3. **Run database migrations** + ```bash + alembic upgrade head + ``` + +4. **Seed initial data** (optional) + ```bash + # Start the server first, then: + curl -X POST http://localhost:8000/api/v1/stats/seed + ``` + +## Running the Application + +### Development +```bash +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Production +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +## API Documentation + +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc +- **OpenAPI JSON**: http://localhost:8000/openapi.json + +## API Endpoints + +### Core +- `GET /` - API information +- `GET /health` - Health check + +### Authentication +- `POST /api/v1/auth/register` - User registration +- `POST /api/v1/auth/login` - User login +- `GET /api/v1/auth/oauth/{provider}` - OAuth login +- `GET /api/v1/auth/oauth/{provider}/callback` - OAuth callback + +### Testimonials +- `GET /api/v1/testimonials/` - List testimonials +- `POST /api/v1/testimonials/` - Create testimonial +- `GET /api/v1/testimonials/{id}` - Get testimonial +- `PUT /api/v1/testimonials/{id}` - Update testimonial +- `DELETE /api/v1/testimonials/{id}` - Delete testimonial + +### Usage Statistics +- `GET /api/v1/stats/` - Get all statistics +- `GET /api/v1/stats/summary` - Get statistics summary +- `GET /api/v1/stats/{metric_name}` - Get metric history +- `POST /api/v1/stats/` - Create/update statistic +- `POST /api/v1/stats/seed` - Seed default statistics + +### Communication +- `POST /api/v1/communication/newsletter/subscribe` - Newsletter signup +- `POST /api/v1/communication/contact` - Contact form +- `POST /api/v1/communication/sales/inquiry` - Sales inquiry +- `GET /api/v1/communication/support/chat/config` - Chat widget config + +### Payments +- `GET /api/v1/payments/plans` - Get pricing plans +- `POST /api/v1/payments/checkout` - Create checkout session +- `POST /api/v1/payments/webhook` - Stripe webhook +- `GET /api/v1/payments/subscription/{user_id}` - Get subscription +- `POST /api/v1/payments/subscription/manage` - Manage subscription +- `POST /api/v1/payments/setup-products` - Setup Stripe products + +## Database + +Uses SQLite by default with the following location: +- **Database path**: `/app/storage/db/db.sqlite` + +### Migrations + +Create new migration: +```bash +alembic revision -m "description" +``` + +Apply migrations: +```bash +alembic upgrade head +``` + +## Code Quality + +The project uses Ruff for linting and formatting: + +```bash +# Install ruff +pip install ruff + +# Lint and fix +ruff check --fix . + +# Format +ruff format . +``` + +## Security + +- JWT tokens for authentication +- Password hashing with bcrypt +- OAuth integration for secure third-party login +- Environment variables for sensitive data +- CORS middleware for cross-origin requests + +## Deployment Notes + +1. Set all required environment variables +2. Use a proper database (PostgreSQL) in production +3. Configure proper CORS origins +4. Set up SSL/TLS certificates +5. Use a reverse proxy (nginx) +6. Set up monitoring and logging + +## Support + +- API Documentation: `/docs` +- Health Check: `/health` +- Issues: Contact your development team + +--- + +Built with FastAPI and BackendIM AI Code Generation \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..524373a --- /dev/null +++ b/alembic.ini @@ -0,0 +1,94 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(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 +# truncate_slug_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 file format +version_file_format = %%(rev)s_%%(slug)s + +# Interpret the config file's logging configuration +# This is the config file of the alembic runner +# and should not be confused with the log_level setting +# for the migration environment +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# 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 new file mode 100644 index 0000000..5d88822 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,82 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import sys +import os + +# Add the project root directory to Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Import your models +from app.db.base import Base + +# 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 new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${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 new file mode 100644 index 0000000..02ed875 --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,94 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00: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('username', sa.String(), nullable=True), + sa.Column('full_name', sa.String(), nullable=True), + sa.Column('hashed_password', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_verified', sa.Boolean(), nullable=True), + sa.Column('avatar_url', sa.String(), nullable=True), + sa.Column('google_id', sa.String(), nullable=True), + sa.Column('github_id', sa.String(), nullable=True), + sa.Column('apple_id', sa.String(), nullable=True), + sa.Column('stripe_customer_id', sa.String(), nullable=True), + sa.Column('subscription_status', sa.String(), nullable=True), + sa.Column('subscription_plan', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), 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_username'), 'users', ['username'], unique=True) + op.create_index(op.f('ix_users_google_id'), 'users', ['google_id'], unique=True) + op.create_index(op.f('ix_users_github_id'), 'users', ['github_id'], unique=True) + op.create_index(op.f('ix_users_apple_id'), 'users', ['apple_id'], unique=True) + + # Create testimonials table + op.create_table( + 'testimonials', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('company', sa.String(), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('avatar_url', sa.String(), nullable=True), + sa.Column('rating', sa.Float(), nullable=True), + sa.Column('is_featured', sa.Boolean(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_testimonials_id'), 'testimonials', ['id'], unique=False) + + # Create usage_stats table + op.create_table( + 'usage_stats', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('metric_name', sa.String(), nullable=False), + sa.Column('metric_value', sa.BigInteger(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_usage_stats_id'), 'usage_stats', ['id'], unique=False) + op.create_index(op.f('ix_usage_stats_metric_name'), 'usage_stats', ['metric_name'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_usage_stats_metric_name'), table_name='usage_stats') + op.drop_index(op.f('ix_usage_stats_id'), table_name='usage_stats') + op.drop_table('usage_stats') + + op.drop_index(op.f('ix_testimonials_id'), table_name='testimonials') + op.drop_table('testimonials') + + op.drop_index(op.f('ix_users_apple_id'), table_name='users') + op.drop_index(op.f('ix_users_github_id'), table_name='users') + op.drop_index(op.f('ix_users_google_id'), table_name='users') + op.drop_index(op.f('ix_users_username'), 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/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..26578a0 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from app.api.v1 import auth, testimonials, usage_stats, communication, payments + +api_router = APIRouter() + +api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +api_router.include_router(testimonials.router, prefix="/testimonials", tags=["Testimonials"]) +api_router.include_router(usage_stats.router, prefix="/stats", tags=["Usage Statistics"]) +api_router.include_router(communication.router, prefix="/communication", tags=["Communication"]) +api_router.include_router(payments.router, prefix="/payments", tags=["Payments"]) \ No newline at end of file diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..7151c8c --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,149 @@ +from datetime import timedelta +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse +import httpx + +from app.db.session import get_db +from app.core.config import settings +from app.core.security import create_access_token +from app.schemas.user import User, UserCreate, Token +from app.services.user_service import UserService +from app.services.email_service import EmailService +from app.auth.oauth import oauth + +router = APIRouter() + +@router.post("/register", response_model=User) +async def register( + user: UserCreate, + db: Session = Depends(get_db) +): + user_service = UserService(db) + + # Check if user already exists + if user_service.get_user_by_email(user.email): + raise HTTPException( + status_code=400, + detail="Email already registered" + ) + + # Create user + db_user = user_service.create_user(user) + + # Send welcome email + email_service = EmailService() + await email_service.send_welcome_email(db_user.email, db_user.full_name or "User") + + return db_user + +@router.post("/login", response_model=Token) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) +): + user_service = UserService(db) + user = user_service.authenticate_user(form_data.username, form_data.password) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + +@router.get("/oauth/{provider}") +async def oauth_login(provider: str, request: Request): + if provider not in ["google", "github", "apple"]: + raise HTTPException(status_code=400, detail="Invalid OAuth provider") + + client = oauth.create_client(provider) + redirect_uri = f"{request.url.scheme}://{request.url.netloc}/api/v1/auth/oauth/{provider}/callback" + + return await client.authorize_redirect(request, redirect_uri) + +@router.get("/oauth/{provider}/callback") +async def oauth_callback(provider: str, request: Request, db: Session = Depends(get_db)): + if provider not in ["google", "github", "apple"]: + raise HTTPException(status_code=400, detail="Invalid OAuth provider") + + client = oauth.create_client(provider) + token = await client.authorize_access_token(request) + + user_service = UserService(db) + + if provider == "google": + user_info = token.get('userinfo') + if user_info: + email = user_info['email'] + name = user_info.get('name', '') + picture = user_info.get('picture', '') + provider_id = user_info['sub'] + + elif provider == "github": + # Get user info from GitHub API + async with httpx.AsyncClient() as client_http: + user_resp = await client_http.get( + 'https://api.github.com/user', + headers={'Authorization': f"Bearer {token['access_token']}"} + ) + user_data = user_resp.json() + + # Get user email + email_resp = await client_http.get( + 'https://api.github.com/user/emails', + headers={'Authorization': f"Bearer {token['access_token']}"} + ) + emails = email_resp.json() + primary_email = next((e['email'] for e in emails if e['primary']), None) + + email = primary_email or user_data.get('email') + name = user_data.get('name', '') + picture = user_data.get('avatar_url', '') + provider_id = str(user_data['id']) + + elif provider == "apple": + # Apple token handling is more complex, simplified here + user_info = token.get('userinfo', {}) + email = user_info.get('email', '') + name = user_info.get('name', '') + picture = '' + provider_id = user_info.get('sub', '') + + # Check if user exists + existing_user = user_service.get_user_by_email(email) + + if existing_user: + user = existing_user + else: + # Create new user + user = user_service.create_oauth_user( + email=email, + full_name=name, + provider=provider, + provider_id=provider_id, + avatar_url=picture + ) + + # Send welcome email + email_service = EmailService() + await email_service.send_welcome_email(user.email, user.full_name or "User") + + # Create access token + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + + # Redirect to frontend with token + return RedirectResponse( + url=f"{settings.frontend_url}/auth/callback?token={access_token}" + ) \ No newline at end of file diff --git a/app/api/v1/communication.py b/app/api/v1/communication.py new file mode 100644 index 0000000..317934b --- /dev/null +++ b/app/api/v1/communication.py @@ -0,0 +1,110 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, EmailStr +from typing import Optional + +from app.services.email_service import EmailService + +router = APIRouter() + +class NewsletterSubscription(BaseModel): + email: EmailStr + name: Optional[str] = None + +class ContactForm(BaseModel): + name: str + email: EmailStr + company: Optional[str] = None + message: str + phone: Optional[str] = None + +class SalesInquiry(BaseModel): + name: str + email: EmailStr + company: str + employees: str + use_case: str + message: Optional[str] = None + +@router.post("/newsletter/subscribe") +async def subscribe_to_newsletter(subscription: NewsletterSubscription): + email_service = EmailService() + + try: + success = await email_service.send_newsletter_signup_confirmation(subscription.email) + + if success: + return {"message": "Successfully subscribed to newsletter"} + else: + raise HTTPException(status_code=500, detail="Failed to send confirmation email") + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error processing subscription: {str(e)}") + +@router.post("/contact") +async def contact_form(contact: ContactForm): + email_service = EmailService() + + try: + # Send confirmation to user + user_confirmation = await email_service.send_contact_confirmation( + contact.email, + contact.name + ) + + # Send notification to admin + admin_notification = await email_service.send_contact_notification( + contact.name, + contact.email, + contact.company, + contact.message + ) + + if user_confirmation and admin_notification: + return {"message": "Contact form submitted successfully"} + else: + raise HTTPException(status_code=500, detail="Failed to process contact form") + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error processing contact form: {str(e)}") + +@router.post("/sales/inquiry") +async def sales_inquiry(inquiry: SalesInquiry): + email_service = EmailService() + + try: + # Send confirmation to prospect + prospect_confirmation = await email_service.send_sales_inquiry_confirmation( + inquiry.email, + inquiry.name + ) + + # Send notification to sales team + sales_notification = await email_service.send_sales_inquiry_notification( + inquiry.name, + inquiry.email, + inquiry.company, + inquiry.employees, + inquiry.use_case, + inquiry.message + ) + + if prospect_confirmation and sales_notification: + return {"message": "Sales inquiry submitted successfully"} + else: + raise HTTPException(status_code=500, detail="Failed to process sales inquiry") + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error processing sales inquiry: {str(e)}") + +@router.get("/support/chat/config") +async def get_chat_config(): + # Return configuration for chat widget (Intercom, Zendesk, etc.) + return { + "enabled": True, + "provider": "intercom", + "app_id": "your_intercom_app_id", # Should come from environment + "settings": { + "color": "#0066cc", + "position": "bottom-right" + } + } \ No newline at end of file diff --git a/app/api/v1/payments.py b/app/api/v1/payments.py new file mode 100644 index 0000000..df84565 --- /dev/null +++ b/app/api/v1/payments.py @@ -0,0 +1,241 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional + +from app.db.session import get_db +from app.models.user import User +from app.services.stripe_service import StripeService +from app.services.user_service import UserService +from app.core.config import settings + +router = APIRouter() + +class CheckoutRequest(BaseModel): + plan_id: str + user_email: str + success_url: Optional[str] = None + cancel_url: Optional[str] = None + +class SubscriptionUpdate(BaseModel): + subscription_id: str + action: str # "cancel", "pause", "resume" + +@router.get("/plans") +async def get_pricing_plans(): + return { + "plans": [ + { + "id": "starter", + "name": "Starter Plan", + "price": 9.99, + "currency": "USD", + "interval": "month", + "features": [ + "Up to 10 projects", + "Basic templates", + "Email support", + "5GB storage" + ], + "stripe_price_id": "price_starter_monthly" + }, + { + "id": "professional", + "name": "Professional Plan", + "price": 29.99, + "currency": "USD", + "interval": "month", + "features": [ + "Unlimited projects", + "Premium templates", + "Priority support", + "50GB storage", + "Advanced analytics", + "Custom branding" + ], + "stripe_price_id": "price_pro_monthly" + }, + { + "id": "business", + "name": "Business Plan", + "price": 99.99, + "currency": "USD", + "interval": "month", + "features": [ + "Everything in Professional", + "Team collaboration", + "API access", + "500GB storage", + "White-label solution", + "Dedicated account manager" + ], + "stripe_price_id": "price_business_monthly" + }, + { + "id": "enterprise", + "name": "Enterprise Plan", + "price": 299.99, + "currency": "USD", + "interval": "month", + "features": [ + "Everything in Business", + "Unlimited storage", + "Custom integrations", + "SLA guarantee", + "On-premise deployment", + "24/7 phone support" + ], + "stripe_price_id": "price_enterprise_monthly" + } + ] + } + +@router.post("/checkout") +async def create_checkout_session( + checkout_request: CheckoutRequest, + db: Session = Depends(get_db) +): + stripe_service = StripeService() + user_service = UserService(db) + + # Get or create user + user = user_service.get_user_by_email(checkout_request.user_email) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get or create Stripe customer + if not user.stripe_customer_id: + customer_result = await stripe_service.create_customer( + email=user.email, + name=user.full_name + ) + + if not customer_result["success"]: + raise HTTPException(status_code=400, detail="Failed to create customer") + + user.stripe_customer_id = customer_result["customer"]["id"] + db.commit() + + # Map plan IDs to Stripe price IDs + price_mapping = { + "starter": "price_starter_monthly", + "professional": "price_pro_monthly", + "business": "price_business_monthly", + "enterprise": "price_enterprise_monthly" + } + + stripe_price_id = price_mapping.get(checkout_request.plan_id) + if not stripe_price_id: + raise HTTPException(status_code=400, detail="Invalid plan ID") + + # Create checkout session + success_url = checkout_request.success_url or f"{settings.frontend_url}/subscription/success" + cancel_url = checkout_request.cancel_url or f"{settings.frontend_url}/pricing" + + session_result = await stripe_service.create_checkout_session( + price_id=stripe_price_id, + customer_id=user.stripe_customer_id, + success_url=success_url, + cancel_url=cancel_url + ) + + if not session_result["success"]: + raise HTTPException(status_code=400, detail="Failed to create checkout session") + + return { + "checkout_url": session_result["session"]["url"], + "session_id": session_result["session"]["id"] + } + +@router.post("/webhook") +async def stripe_webhook(request: Request, db: Session = Depends(get_db)): + payload = await request.body() + sig_header = request.headers.get('stripe-signature') + + try: + import stripe + event = stripe.Webhook.construct_event( + payload, sig_header, settings.stripe_webhook_secret + ) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payload") + except stripe.error.SignatureVerificationError: + raise HTTPException(status_code=400, detail="Invalid signature") + + # Handle the event + if event['type'] == 'checkout.session.completed': + session = event['data']['object'] + customer_id = session['customer'] + + # Update user subscription status + user = db.query(User).filter(User.stripe_customer_id == customer_id).first() + if user: + user.subscription_status = "active" + user.subscription_plan = "premium" # You might want to derive this from the session + db.commit() + + elif event['type'] == 'customer.subscription.updated': + subscription = event['data']['object'] + customer_id = subscription['customer'] + + user = db.query(User).filter(User.stripe_customer_id == customer_id).first() + if user: + user.subscription_status = subscription['status'] + db.commit() + + elif event['type'] == 'customer.subscription.deleted': + subscription = event['data']['object'] + customer_id = subscription['customer'] + + user = db.query(User).filter(User.stripe_customer_id == customer_id).first() + if user: + user.subscription_status = "cancelled" + user.subscription_plan = None + db.commit() + + return {"status": "success"} + +@router.get("/subscription/{user_id}") +async def get_user_subscription(user_id: int, db: Session = Depends(get_db)): + user_service = UserService(db) + user = user_service.get_user(user_id) + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return { + "status": user.subscription_status, + "plan": user.subscription_plan, + "stripe_customer_id": user.stripe_customer_id + } + +@router.post("/subscription/manage") +async def manage_subscription( + subscription_update: SubscriptionUpdate, + db: Session = Depends(get_db) +): + stripe_service = StripeService() + + if subscription_update.action == "cancel": + result = await stripe_service.cancel_subscription(subscription_update.subscription_id) + + if result["success"]: + return {"message": "Subscription cancelled successfully"} + else: + raise HTTPException(status_code=400, detail="Failed to cancel subscription") + + # Add more subscription management actions as needed + raise HTTPException(status_code=400, detail="Invalid action") + +@router.post("/setup-products") +async def setup_stripe_products(): + stripe_service = StripeService() + + try: + products = await stripe_service.create_product_and_prices() + return { + "message": "Products and prices created successfully", + "products": len(products) + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error setting up products: {str(e)}") \ No newline at end of file diff --git a/app/api/v1/testimonials.py b/app/api/v1/testimonials.py new file mode 100644 index 0000000..929eaa1 --- /dev/null +++ b/app/api/v1/testimonials.py @@ -0,0 +1,76 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.testimonial import Testimonial +from app.schemas.testimonial import Testimonial as TestimonialSchema, TestimonialCreate, TestimonialUpdate + +router = APIRouter() + +@router.get("/", response_model=List[TestimonialSchema]) +def get_testimonials( + featured_only: bool = False, + limit: int = 10, + db: Session = Depends(get_db) +): + query = db.query(Testimonial).filter(Testimonial.is_active) + + if featured_only: + query = query.filter(Testimonial.is_featured) + + testimonials = query.order_by(Testimonial.created_at.desc()).limit(limit).all() + return testimonials + +@router.get("/{testimonial_id}", response_model=TestimonialSchema) +def get_testimonial(testimonial_id: int, db: Session = Depends(get_db)): + testimonial = db.query(Testimonial).filter( + Testimonial.id == testimonial_id, + Testimonial.is_active + ).first() + + if not testimonial: + raise HTTPException(status_code=404, detail="Testimonial not found") + + return testimonial + +@router.post("/", response_model=TestimonialSchema) +def create_testimonial( + testimonial: TestimonialCreate, + db: Session = Depends(get_db) +): + db_testimonial = Testimonial(**testimonial.dict()) + db.add(db_testimonial) + db.commit() + db.refresh(db_testimonial) + return db_testimonial + +@router.put("/{testimonial_id}", response_model=TestimonialSchema) +def update_testimonial( + testimonial_id: int, + testimonial_update: TestimonialUpdate, + db: Session = Depends(get_db) +): + testimonial = db.query(Testimonial).filter(Testimonial.id == testimonial_id).first() + + if not testimonial: + raise HTTPException(status_code=404, detail="Testimonial not found") + + for field, value in testimonial_update.dict(exclude_unset=True).items(): + setattr(testimonial, field, value) + + db.commit() + db.refresh(testimonial) + return testimonial + +@router.delete("/{testimonial_id}") +def delete_testimonial(testimonial_id: int, db: Session = Depends(get_db)): + testimonial = db.query(Testimonial).filter(Testimonial.id == testimonial_id).first() + + if not testimonial: + raise HTTPException(status_code=404, detail="Testimonial not found") + + testimonial.is_active = False + db.commit() + + return {"message": "Testimonial deleted successfully"} \ No newline at end of file diff --git a/app/api/v1/usage_stats.py b/app/api/v1/usage_stats.py new file mode 100644 index 0000000..c037962 --- /dev/null +++ b/app/api/v1/usage_stats.py @@ -0,0 +1,128 @@ +from typing import List, Dict, Any +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.db.session import get_db +from app.models.usage_stat import UsageStat +from app.schemas.usage_stat import UsageStat as UsageStatSchema, UsageStatCreate, UsageStatUpdate + +router = APIRouter() + +@router.get("/", response_model=List[UsageStatSchema]) +def get_usage_stats(db: Session = Depends(get_db)): + # Get the latest value for each metric + subquery = db.query( + UsageStat.metric_name, + func.max(UsageStat.created_at).label('max_created_at') + ).group_by(UsageStat.metric_name).subquery() + + stats = db.query(UsageStat).join( + subquery, + (UsageStat.metric_name == subquery.c.metric_name) & + (UsageStat.created_at == subquery.c.max_created_at) + ).all() + + return stats + +@router.get("/summary") +def get_stats_summary(db: Session = Depends(get_db)) -> Dict[str, Any]: + # Get the latest value for each metric in a summary format + subquery = db.query( + UsageStat.metric_name, + func.max(UsageStat.created_at).label('max_created_at') + ).group_by(UsageStat.metric_name).subquery() + + stats = db.query(UsageStat).join( + subquery, + (UsageStat.metric_name == subquery.c.metric_name) & + (UsageStat.created_at == subquery.c.max_created_at) + ).all() + + summary = {} + for stat in stats: + summary[stat.metric_name] = { + "value": stat.metric_value, + "description": stat.description, + "last_updated": stat.created_at + } + + return summary + +@router.get("/{metric_name}") +def get_metric_history( + metric_name: str, + limit: int = 10, + db: Session = Depends(get_db) +): + stats = db.query(UsageStat).filter( + UsageStat.metric_name == metric_name + ).order_by(UsageStat.created_at.desc()).limit(limit).all() + + if not stats: + raise HTTPException(status_code=404, detail="Metric not found") + + return stats + +@router.post("/", response_model=UsageStatSchema) +def create_usage_stat( + stat: UsageStatCreate, + db: Session = Depends(get_db) +): + db_stat = UsageStat(**stat.dict()) + db.add(db_stat) + db.commit() + db.refresh(db_stat) + return db_stat + +@router.put("/{metric_name}", response_model=UsageStatSchema) +def update_usage_stat( + metric_name: str, + stat_update: UsageStatUpdate, + db: Session = Depends(get_db) +): + # Create a new entry instead of updating (for historical tracking) + latest_stat = db.query(UsageStat).filter( + UsageStat.metric_name == metric_name + ).order_by(UsageStat.created_at.desc()).first() + + if not latest_stat: + raise HTTPException(status_code=404, detail="Metric not found") + + new_stat = UsageStat( + metric_name=metric_name, + metric_value=stat_update.metric_value if stat_update.metric_value is not None else latest_stat.metric_value, + description=stat_update.description if stat_update.description is not None else latest_stat.description + ) + + db.add(new_stat) + db.commit() + db.refresh(new_stat) + return new_stat + +@router.post("/seed") +def seed_usage_stats(db: Session = Depends(get_db)): + # Seed some default usage statistics + default_stats = [ + {"metric_name": "total_users", "metric_value": 150000, "description": "Total registered users"}, + {"metric_name": "content_created", "metric_value": 2500000, "description": "Total pieces of content created"}, + {"metric_name": "active_creators", "metric_value": 45000, "description": "Active content creators this month"}, + {"metric_name": "businesses_served", "metric_value": 12000, "description": "Businesses using our platform"}, + {"metric_name": "conversion_rate", "metric_value": 15, "description": "Average conversion rate increase (%)"}, + {"metric_name": "time_saved", "metric_value": 80, "description": "Average time saved per project (%)"} + ] + + created_stats = [] + for stat_data in default_stats: + # Only create if it doesn't exist + existing = db.query(UsageStat).filter( + UsageStat.metric_name == stat_data["metric_name"] + ).first() + + if not existing: + stat = UsageStat(**stat_data) + db.add(stat) + created_stats.append(stat_data["metric_name"]) + + db.commit() + return {"message": f"Created stats for: {', '.join(created_stats)}"} \ No newline at end of file diff --git a/app/auth/oauth.py b/app/auth/oauth.py new file mode 100644 index 0000000..063ee16 --- /dev/null +++ b/app/auth/oauth.py @@ -0,0 +1,39 @@ +from authlib.integrations.starlette_client import OAuth +from starlette.config import Config +from app.core.config import settings + +config = Config() + +oauth = OAuth(config) + +# Google OAuth +oauth.register( + name='google', + client_id=settings.google_client_id, + client_secret=settings.google_client_secret, + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email profile' + } +) + +# GitHub OAuth +oauth.register( + name='github', + client_id=settings.github_client_id, + client_secret=settings.github_client_secret, + access_token_url='https://github.com/login/oauth/access_token', + authorize_url='https://github.com/login/oauth/authorize', + api_base_url='https://api.github.com/', + client_kwargs={'scope': 'user:email'}, +) + +# Apple OAuth +oauth.register( + name='apple', + client_id=settings.apple_client_id, + client_secret=settings.apple_private_key, + authorize_url='https://appleid.apple.com/auth/authorize', + access_token_url='https://appleid.apple.com/auth/token', + client_kwargs={'scope': 'name email'}, +) \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..37a7ffa --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,41 @@ +import os +from pydantic import BaseSettings + +class Settings(BaseSettings): + app_name: str = "Landing Page Backend API" + debug: bool = True + + # Database + database_url: str = "sqlite:////app/storage/db/db.sqlite" + + # JWT + secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + + # OAuth + google_client_id: str = os.getenv("GOOGLE_CLIENT_ID", "") + google_client_secret: str = os.getenv("GOOGLE_CLIENT_SECRET", "") + github_client_id: str = os.getenv("GITHUB_CLIENT_ID", "") + github_client_secret: str = os.getenv("GITHUB_CLIENT_SECRET", "") + apple_client_id: str = os.getenv("APPLE_CLIENT_ID", "") + apple_team_id: str = os.getenv("APPLE_TEAM_ID", "") + apple_key_id: str = os.getenv("APPLE_KEY_ID", "") + apple_private_key: str = os.getenv("APPLE_PRIVATE_KEY", "") + + # Stripe + stripe_publishable_key: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "") + stripe_secret_key: str = os.getenv("STRIPE_SECRET_KEY", "") + stripe_webhook_secret: str = os.getenv("STRIPE_WEBHOOK_SECRET", "") + + # Email + sendgrid_api_key: str = os.getenv("SENDGRID_API_KEY", "") + from_email: str = os.getenv("FROM_EMAIL", "noreply@example.com") + + # Frontend URL + frontend_url: str = os.getenv("FRONTEND_URL", "http://localhost:3000") + + class Config: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..558f209 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.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[dict]: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + return payload + except JWTError: + return None \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..7c2377a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..2677e30 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,26 @@ +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() + +def create_tables(): + Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..8236e48 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.user import User +from app.models.testimonial import Testimonial +from app.models.usage_stat import UsageStat + +__all__ = ["User", "Testimonial", "UsageStat"] \ No newline at end of file diff --git a/app/models/testimonial.py b/app/models/testimonial.py new file mode 100644 index 0000000..d5d4696 --- /dev/null +++ b/app/models/testimonial.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Float +from sqlalchemy.sql import func +from app.db.base import Base + +class Testimonial(Base): + __tablename__ = "testimonials" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + title = Column(String, nullable=True) + company = Column(String, nullable=True) + content = Column(Text, nullable=False) + avatar_url = Column(String, nullable=True) + rating = Column(Float, default=5.0) + is_featured = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/app/models/usage_stat.py b/app/models/usage_stat.py new file mode 100644 index 0000000..963dee3 --- /dev/null +++ b/app/models/usage_stat.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, DateTime, BigInteger +from sqlalchemy.sql import func +from app.db.base import Base + +class UsageStat(Base): + __tablename__ = "usage_stats" + + id = Column(Integer, primary_key=True, index=True) + metric_name = Column(String, nullable=False, index=True) + metric_value = Column(BigInteger, nullable=False) + description = Column(String, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..78e08ee --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +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) + username = Column(String, unique=True, index=True, nullable=True) + full_name = Column(String, nullable=True) + hashed_password = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + avatar_url = Column(String, nullable=True) + + # OAuth fields + google_id = Column(String, unique=True, nullable=True) + github_id = Column(String, unique=True, nullable=True) + apple_id = Column(String, unique=True, nullable=True) + + # Subscription info + stripe_customer_id = Column(String, nullable=True) + subscription_status = Column(String, default="free") + subscription_plan = Column(String, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..79b947b --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,9 @@ +from app.schemas.user import User, UserCreate, UserUpdate, Token, TokenData +from app.schemas.testimonial import Testimonial, TestimonialCreate, TestimonialUpdate +from app.schemas.usage_stat import UsageStat, UsageStatCreate, UsageStatUpdate + +__all__ = [ + "User", "UserCreate", "UserUpdate", "Token", "TokenData", + "Testimonial", "TestimonialCreate", "TestimonialUpdate", + "UsageStat", "UsageStatCreate", "UsageStatUpdate" +] \ No newline at end of file diff --git a/app/schemas/testimonial.py b/app/schemas/testimonial.py new file mode 100644 index 0000000..108e98d --- /dev/null +++ b/app/schemas/testimonial.py @@ -0,0 +1,33 @@ +from typing import Optional +from pydantic import BaseModel +from datetime import datetime + +class TestimonialBase(BaseModel): + name: str + title: Optional[str] = None + company: Optional[str] = None + content: str + avatar_url: Optional[str] = None + rating: float = 5.0 + +class TestimonialCreate(TestimonialBase): + pass + +class TestimonialUpdate(BaseModel): + name: Optional[str] = None + title: Optional[str] = None + company: Optional[str] = None + content: Optional[str] = None + avatar_url: Optional[str] = None + rating: Optional[float] = None + is_featured: Optional[bool] = None + is_active: Optional[bool] = None + +class Testimonial(TestimonialBase): + id: int + is_featured: bool + is_active: bool + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/usage_stat.py b/app/schemas/usage_stat.py new file mode 100644 index 0000000..96b4424 --- /dev/null +++ b/app/schemas/usage_stat.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import BaseModel +from datetime import datetime + +class UsageStatBase(BaseModel): + metric_name: str + metric_value: int + description: Optional[str] = None + +class UsageStatCreate(UsageStatBase): + pass + +class UsageStatUpdate(BaseModel): + metric_value: Optional[int] = None + description: Optional[str] = None + +class UsageStat(UsageStatBase): + id: int + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..d666146 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,38 @@ +from typing import Optional +from pydantic import BaseModel, EmailStr +from datetime import datetime + +class UserBase(BaseModel): + email: EmailStr + username: Optional[str] = None + full_name: Optional[str] = None + +class UserCreate(UserBase): + password: str + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + full_name: Optional[str] = None + +class UserInDB(UserBase): + id: int + is_active: bool + is_verified: bool + avatar_url: Optional[str] = None + subscription_status: str + subscription_plan: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + +class User(UserInDB): + pass + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + email: Optional[str] = None \ No newline at end of file diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 0000000..5f25c4f --- /dev/null +++ b/app/services/email_service.py @@ -0,0 +1,130 @@ +import sendgrid +from sendgrid.helpers.mail import Mail +from app.core.config import settings + +class EmailService: + def __init__(self): + self.sg = sendgrid.SendGridAPIClient(api_key=settings.sendgrid_api_key) + self.from_email = settings.from_email + + async def send_welcome_email(self, to_email: str, user_name: str): + message = Mail( + from_email=self.from_email, + to_emails=to_email, + subject="Welcome to our platform!", + html_content=f""" +
Thank you for joining our platform. We're excited to have you on board!
+Get started by exploring our features and creating your first project.
+ """ + ) + + try: + response = self.sg.send(message) + return response.status_code == 202 + except Exception as e: + print(f"Error sending email: {e}") + return False + + async def send_newsletter_signup_confirmation(self, to_email: str): + message = Mail( + from_email=self.from_email, + to_emails=to_email, + subject="Newsletter Subscription Confirmed", + html_content=""" +Thank you for subscribing to our newsletter.
+You'll receive the latest updates, tips, and product announcements.
+ """ + ) + + try: + response = self.sg.send(message) + return response.status_code == 202 + except Exception as e: + print(f"Error sending email: {e}") + return False + + async def send_contact_confirmation(self, to_email: str, name: str): + message = Mail( + from_email=self.from_email, + to_emails=to_email, + subject="Contact Form Received", + html_content=f""" +We've received your message and will get back to you within 24 hours.
+Our team is reviewing your inquiry and will provide a detailed response soon.
+ """ + ) + + try: + response = self.sg.send(message) + return response.status_code == 202 + except Exception as e: + print(f"Error sending email: {e}") + return False + + async def send_contact_notification(self, name: str, email: str, company: str, message: str): + admin_message = Mail( + from_email=self.from_email, + to_emails="admin@example.com", # Should come from settings + subject=f"New Contact Form Submission from {name}", + html_content=f""" +Name: {name}
+Email: {email}
+Company: {company or 'Not provided'}
+Message:
+{message}
+ """ + ) + + try: + response = self.sg.send(admin_message) + return response.status_code == 202 + except Exception as e: + print(f"Error sending email: {e}") + return False + + async def send_sales_inquiry_confirmation(self, to_email: str, name: str): + message = Mail( + from_email=self.from_email, + to_emails=to_email, + subject="Sales Inquiry Received", + html_content=f""" +We've received your sales inquiry and our team will contact you within 2 business hours.
+In the meantime, feel free to explore our documentation and resources.
+ """ + ) + + try: + response = self.sg.send(message) + return response.status_code == 202 + except Exception as e: + print(f"Error sending email: {e}") + return False + + async def send_sales_inquiry_notification(self, name: str, email: str, company: str, employees: str, use_case: str, message: str): + sales_message = Mail( + from_email=self.from_email, + to_emails="sales@example.com", # Should come from settings + subject=f"New Sales Inquiry from {company}", + html_content=f""" +Name: {name}
+Email: {email}
+Company: {company}
+Company Size: {employees}
+Use Case: {use_case}
+Additional Message:
+{message or 'None provided'}
+ """ + ) + + try: + response = self.sg.send(sales_message) + return response.status_code == 202 + except Exception as e: + print(f"Error sending email: {e}") + return False \ No newline at end of file diff --git a/app/services/stripe_service.py b/app/services/stripe_service.py new file mode 100644 index 0000000..92da271 --- /dev/null +++ b/app/services/stripe_service.py @@ -0,0 +1,90 @@ +import stripe +from typing import Dict, Any +from app.core.config import settings + +stripe.api_key = settings.stripe_secret_key + +class StripeService: + def __init__(self): + self.stripe = stripe + + async def create_customer(self, email: str, name: str = None) -> Dict[str, Any]: + try: + customer = self.stripe.Customer.create( + email=email, + name=name + ) + return {"success": True, "customer": customer} + except stripe.error.StripeError as e: + return {"success": False, "error": str(e)} + + async def create_checkout_session(self, price_id: str, customer_id: str, success_url: str, cancel_url: str) -> Dict[str, Any]: + try: + session = self.stripe.checkout.Session.create( + customer=customer_id, + payment_method_types=['card'], + line_items=[{ + 'price': price_id, + 'quantity': 1, + }], + mode='subscription', + success_url=success_url, + cancel_url=cancel_url, + ) + return {"success": True, "session": session} + except stripe.error.StripeError as e: + return {"success": False, "error": str(e)} + + async def get_subscription(self, subscription_id: str) -> Dict[str, Any]: + try: + subscription = self.stripe.Subscription.retrieve(subscription_id) + return {"success": True, "subscription": subscription} + except stripe.error.StripeError as e: + return {"success": False, "error": str(e)} + + async def cancel_subscription(self, subscription_id: str) -> Dict[str, Any]: + try: + subscription = self.stripe.Subscription.delete(subscription_id) + return {"success": True, "subscription": subscription} + except stripe.error.StripeError as e: + return {"success": False, "error": str(e)} + + async def create_product_and_prices(self): + products = [ + { + "name": "Starter Plan", + "prices": [{"amount": 999, "interval": "month"}] + }, + { + "name": "Professional Plan", + "prices": [{"amount": 2999, "interval": "month"}] + }, + { + "name": "Business Plan", + "prices": [{"amount": 9999, "interval": "month"}] + }, + { + "name": "Enterprise Plan", + "prices": [{"amount": 29999, "interval": "month"}] + } + ] + + created_products = [] + for product_data in products: + try: + product = self.stripe.Product.create(name=product_data["name"]) + for price_data in product_data["prices"]: + price = self.stripe.Price.create( + product=product.id, + unit_amount=price_data["amount"], + currency='usd', + recurring={'interval': price_data["interval"]} + ) + created_products.append({ + "product": product, + "price": price + }) + except stripe.error.StripeError as e: + print(f"Error creating product: {e}") + + return created_products \ No newline at end of file diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..4255617 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,71 @@ +from typing import Optional +from sqlalchemy.orm import Session +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate +from app.core.security import get_password_hash, verify_password + +class UserService: + def __init__(self, db: Session): + self.db = db + + def get_user(self, user_id: int) -> Optional[User]: + return self.db.query(User).filter(User.id == user_id).first() + + def get_user_by_email(self, email: str) -> Optional[User]: + return self.db.query(User).filter(User.email == email).first() + + def get_user_by_username(self, username: str) -> Optional[User]: + return self.db.query(User).filter(User.username == username).first() + + def create_user(self, user: UserCreate) -> User: + hashed_password = get_password_hash(user.password) + db_user = User( + email=user.email, + username=user.username, + full_name=user.full_name, + hashed_password=hashed_password + ) + self.db.add(db_user) + self.db.commit() + self.db.refresh(db_user) + return db_user + + def create_oauth_user(self, email: str, full_name: str, provider: str, provider_id: str, avatar_url: str = None) -> User: + db_user = User( + email=email, + full_name=full_name, + avatar_url=avatar_url, + is_verified=True + ) + + if provider == "google": + db_user.google_id = provider_id + elif provider == "github": + db_user.github_id = provider_id + elif provider == "apple": + db_user.apple_id = provider_id + + self.db.add(db_user) + self.db.commit() + self.db.refresh(db_user) + return db_user + + def update_user(self, user_id: int, user_update: UserUpdate) -> Optional[User]: + db_user = self.get_user(user_id) + if not db_user: + return None + + for field, value in user_update.dict(exclude_unset=True).items(): + setattr(db_user, field, value) + + self.db.commit() + self.db.refresh(db_user) + return db_user + + def authenticate_user(self, email: str, password: str) -> Optional[User]: + user = self.get_user_by_email(email) + if not user or not user.hashed_password: + return None + if not verify_password(password, user.hashed_password): + return None + return user \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..bda8193 --- /dev/null +++ b/main.py @@ -0,0 +1,46 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.api.v1 import api_router +from app.db.session import create_tables +from app.core.config import settings + +app = FastAPI( + title=settings.app_name, + description="Backend API for Landing Page with authentication, payments, and communication features", + version="1.0.0", + openapi_url="/openapi.json" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API routes +app.include_router(api_router, prefix="/api/v1") + +@app.on_event("startup") +async def startup_event(): + create_tables() + +@app.get("/") +async def root(): + return { + "title": settings.app_name, + "description": "Backend API for Landing Page with authentication, payments, and communication features", + "documentation": "/docs", + "health_check": "/health", + "version": "1.0.0" + } + +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "service": settings.app_name, + "version": "1.0.0" + } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..498db31 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.13.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-dotenv==1.0.0 +httpx==0.25.2 +stripe==7.8.0 +sendgrid==6.11.0 +authlib==1.2.1 +itsdangerous==2.1.2 +ruff==0.1.7 \ No newline at end of file