diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..15e4b3aa23a8d28db361048e618960514ee32db6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY alembic ./alembic + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 0049dd44fbaeaae1e28d525d677160db6355c3b7..bf02a4d4765e128b394536f32bbbae7b49500f4e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,34 @@ --- -title: Rideshare Api -emoji: 🌖 -colorFrom: yellow -colorTo: red +title: RideShare API +emoji: 🚕 +colorFrom: indigo +colorTo: emerald sdk: docker +app_port: 8000 pinned: false license: apache-2.0 +short_description: FastAPI backend for the RideShare demo (rider, driver, payments). --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# RideShare API + +FastAPI backend powering the RideShare Android demo. PostgreSQL/PostGIS data lives in Supabase; this service exposes the REST + WebSocket surface the mobile app consumes. + +## Health check + +``` +GET /health → {"status":"ok"} +``` + +## Required secrets (set in HF Space → Settings → Secrets) + +| Key | Source | +|---|---| +| `DATABASE_URL` | Supabase Transaction Pooler URI with `postgresql+asyncpg://` prefix | +| `SUPABASE_URL` | Supabase project URL | +| `SUPABASE_JWT_SECRET` | Supabase → Settings → API → JWT Settings → JWT Secret | +| `GOOGLE_MAPS_KEY` | Google Cloud project key with Directions API enabled | +| `STRIPE_SECRET_KEY` | Stripe dashboard (test mode) | +| `STRIPE_PUBLISHABLE_KEY` | Stripe dashboard (test mode) | + +App reads them at runtime via `pydantic-settings`. Missing required vars will crash on boot — check the build logs. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/__pycache__/__init__.cpython-314.pyc b/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..afebaea19c5a291f52162eb723491581ee310fb2 Binary files /dev/null and b/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/__pycache__/deps.cpython-314.pyc b/app/__pycache__/deps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a041a59199220df0cb8ae972e16863577884387f Binary files /dev/null and b/app/__pycache__/deps.cpython-314.pyc differ diff --git a/app/__pycache__/main.cpython-314.pyc b/app/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd4bc725d2d06fab9bc3144fd3cb0e819e825d01 Binary files /dev/null and b/app/__pycache__/main.cpython-314.pyc differ diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/api/__pycache__/__init__.cpython-314.pyc b/app/api/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48d3e3fd31969291864714654e1823062a5d91cb Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/api/v1/__pycache__/__init__.cpython-314.pyc b/app/api/v1/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a88563a40b755e257239321a892465549d1bd82 Binary files /dev/null and b/app/api/v1/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/api/v1/__pycache__/auth.cpython-314.pyc b/app/api/v1/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab33a8d39f468f8f462589ca738ec103f52e44e6 Binary files /dev/null and b/app/api/v1/__pycache__/auth.cpython-314.pyc differ diff --git a/app/api/v1/__pycache__/drivers.cpython-314.pyc b/app/api/v1/__pycache__/drivers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4080e45fe1c6eead5a674909f259b209f90c656c Binary files /dev/null and b/app/api/v1/__pycache__/drivers.cpython-314.pyc differ diff --git a/app/api/v1/__pycache__/payments.cpython-314.pyc b/app/api/v1/__pycache__/payments.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..139e2338ada409644431786b4e2896ff29f57fff Binary files /dev/null and b/app/api/v1/__pycache__/payments.cpython-314.pyc differ diff --git a/app/api/v1/__pycache__/rides.cpython-314.pyc b/app/api/v1/__pycache__/rides.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4eb4688d1cdf26f2e548ac4d1400edb066a30c07 Binary files /dev/null and b/app/api/v1/__pycache__/rides.cpython-314.pyc differ diff --git a/app/api/v1/__pycache__/users.cpython-314.pyc b/app/api/v1/__pycache__/users.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba8f3a3407963b1ec912a4454b33a252fb21b13e Binary files /dev/null and b/app/api/v1/__pycache__/users.cpython-314.pyc differ diff --git a/app/api/v1/drivers.py b/app/api/v1/drivers.py new file mode 100644 index 0000000000000000000000000000000000000000..40c961a00d5d0f23c2c8374c00f40b12dc7d310a --- /dev/null +++ b/app/api/v1/drivers.py @@ -0,0 +1,41 @@ +import json +from fastapi import APIRouter, Depends, HTTPException +from app.deps import get_current_user +from app.models.profile import Profile, UserRole +from app.schemas.drivers import LocationIn +from app.services.redis_client import r + +router = APIRouter() + + +def _require_driver(user: Profile) -> None: + if user.role != UserRole.driver: + raise HTTPException(403, 'Driver role required') + + +@router.post('/online') +async def go_online(loc: LocationIn, user: Profile = Depends(get_current_user)): + _require_driver(user) + await r.geoadd('drivers:online', (loc.lng, loc.lat, str(user.id))) + await r.hset(f'driver:{user.id}', mapping={'lat': loc.lat, 'lng': loc.lng}) + return {'ok': True} + + +@router.post('/offline') +async def go_offline(user: Profile = Depends(get_current_user)): + _require_driver(user) + await r.zrem('drivers:online', str(user.id)) + await r.delete(f'driver:{user.id}') + return {'ok': True} + + +@router.post('/location') +async def update_location(loc: LocationIn, user: Profile = Depends(get_current_user)): + _require_driver(user) + await r.geoadd('drivers:online', (loc.lng, loc.lat, str(user.id))) + if loc.ride_id is not None: + await r.publish( + f'ride:{loc.ride_id}:driver_location', + json.dumps({'lat': loc.lat, 'lng': loc.lng}), + ) + return {'ok': True} diff --git a/app/api/v1/payments.py b/app/api/v1/payments.py new file mode 100644 index 0000000000000000000000000000000000000000..4eeea6f9cc09b3c72470144d0e794758b1bc80a8 --- /dev/null +++ b/app/api/v1/payments.py @@ -0,0 +1,118 @@ +import logging +from datetime import UTC, datetime + +import stripe +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.deps import get_current_user +from app.models.profile import Profile +from app.models.ride import Ride, RideStatus +from app.schemas.payments import ( + ConfirmRidePaymentIn, + InitRidePaymentIn, + InitRidePaymentOut, + TestChargeIn, + TestChargeOut, +) +from app.services.stripe_service import ( + create_ephemeral_key, + create_payment_intent, + ensure_customer, +) +from app.websockets.manager import manager + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post('/init-ride-payment', response_model=InitRidePaymentOut) +async def init_ride_payment( + data: InitRidePaymentIn, + user: Profile = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ride = await db.get(Ride, data.ride_id) + if ride is None: + raise HTTPException(404, 'Ride not found') + if ride.rider_id != user.id: + raise HTTPException(403, 'Only the rider can pay for this ride') + if ride.fare is None or ride.fare <= 0: + raise HTTPException(400, 'Ride has no fare') + + try: + customer_id = ensure_customer(user) + intent = create_payment_intent( + amount=ride.fare, customer_id=customer_id, ride_id=str(ride.id) + ) + ephemeral = create_ephemeral_key(customer_id) + except stripe.StripeError as exc: + logger.exception('Stripe error initialising ride payment') + raise HTTPException(502, f'Stripe error: {exc.user_message or str(exc)}') from exc + + ride.payment_intent_id = intent['id'] + await db.commit() + + return InitRidePaymentOut( + payment_intent_id=intent['id'], + client_secret=intent['client_secret'], + ephemeral_key=ephemeral, + customer_id=customer_id, + publishable_key=settings.STRIPE_PUBLISHABLE_KEY, + ) + + +@router.post('/confirm-ride-payment') +async def confirm_ride_payment( + data: ConfirmRidePaymentIn, + user: Profile = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ride = await db.get(Ride, data.ride_id) + if ride is None: + raise HTTPException(404, 'Ride not found') + if ride.rider_id != user.id: + raise HTTPException(403, 'Only the rider can confirm this payment') + + ride.status = RideStatus.completed + ride.ended_at = datetime.now(UTC) + await db.commit() + + receipt = { + 'type': 'ride_paid', + 'ride_id': str(ride.id), + 'fare': ride.fare, + } + await manager.send(str(ride.rider_id), receipt) + if ride.driver_id is not None: + await manager.send(str(ride.driver_id), receipt) + + return {'ok': True} + + +@router.post('/test-charge', response_model=TestChargeOut) +async def test_charge( + data: TestChargeIn, + user: Profile = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if data.amount <= 0: + raise HTTPException(400, 'Amount must be greater than zero') + + try: + customer_id = ensure_customer(user) + intent = stripe.PaymentIntent.create( + amount=int(data.amount * 100), + currency=data.currency, + customer=customer_id, + automatic_payment_methods={'enabled': True, 'allow_redirects': 'never'}, + metadata={'profile_id': str(user.id), 'kind': 'test_charge'}, + ) + except stripe.StripeError as exc: + logger.exception('Stripe error creating test charge') + raise HTTPException(502, f'Stripe error: {exc.user_message or str(exc)}') from exc + + await db.commit() + return TestChargeOut(payment_intent_id=intent['id'], client_secret=intent['client_secret']) diff --git a/app/api/v1/rides.py b/app/api/v1/rides.py new file mode 100644 index 0000000000000000000000000000000000000000..bbf5fe6bb7eef654085e8c338906ef151aa77159 --- /dev/null +++ b/app/api/v1/rides.py @@ -0,0 +1,79 @@ +from datetime import UTC, datetime +from fastapi import APIRouter, Depends, HTTPException +from geoalchemy2 import WKTElement +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.deps import get_current_user +from app.models.ride import Ride +from app.services.maps import google_directions +from app.services.matching import find_nearest_driver +from app.services.pricing import estimate_fare +from app.schemas.rides import EstimateIn, EstimateOut, RideRequestIn, RideOut + +router = APIRouter() + + +@router.post('/estimate', response_model=EstimateOut) +async def estimate(data: EstimateIn): + try: + distance_km, duration_min = await google_directions(data.pickup, data.dropoff) + except Exception as exc: + raise HTTPException(400, 'Unable to estimate this route') from exc + + return EstimateOut( + distance_km=distance_km, + duration_min=duration_min, + fare=estimate_fare(distance_km, duration_min, data.vehicle_type), + vehicle_type=data.vehicle_type, + ) + + +@router.post('/request', response_model=RideOut) +async def request_ride( + data: RideRequestIn, + user=Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ride = Ride( + rider_id=user.id, + pickup=WKTElement(f'POINT({data.pickup.lng} {data.pickup.lat})', srid=4326), + dropoff=WKTElement(f'POINT({data.dropoff.lng} {data.dropoff.lat})', srid=4326), + pickup_address=data.pickup_address, + dropoff_address=data.dropoff_address, + fare=data.fare, + distance_km=data.distance_km, + duration_min=data.duration_min, + vehicle_type=data.vehicle_type, + ) + db.add(ride) + try: + await db.commit() + await db.refresh(ride) + except SQLAlchemyError as exc: + await db.rollback() + raise HTTPException(400, 'Unable to save ride request') from exc + + try: + await find_nearest_driver(ride.id, data.pickup) + except HTTPException as exc: + if exc.status_code != 404: + raise + except Exception: + # Ride creation should still succeed if live driver matching is temporarily unavailable. + pass + + return RideOut( + id=ride.id, + rider_id=ride.rider_id, + driver_id=ride.driver_id, + status=ride.status, + fare=ride.fare, + distance_km=ride.distance_km, + duration_min=ride.duration_min, + pickup_address=ride.pickup_address, + dropoff_address=ride.dropoff_address, + vehicle_type=ride.vehicle_type, + requested_at=ride.requested_at or datetime.now(UTC), + ) diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000000000000000000000000000000000000..a22f51a7a4a2658401721816b2241b1b29af6a76 --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.deps import get_current_user +from app.models.profile import Profile +from app.schemas.users import PushTokenIn + +router = APIRouter() + + +@router.post('/push-token') +async def save_push_token( + data: PushTokenIn, + user: Profile = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + user.push_token = data.token + await db.commit() + return {'ok': True} diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/core/__pycache__/__init__.cpython-314.pyc b/app/core/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c3dcfa81a8f8dade1f1ec3a4c997f6a3d6dcdc2 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/core/__pycache__/config.cpython-314.pyc b/app/core/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..87d80e9368ff79ab613ecdc4219449d443b81401 Binary files /dev/null and b/app/core/__pycache__/config.cpython-314.pyc differ diff --git a/app/core/__pycache__/database.cpython-314.pyc b/app/core/__pycache__/database.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6047805e3ca5ce459af5c24370dc82c24eec7e90 Binary files /dev/null and b/app/core/__pycache__/database.cpython-314.pyc differ diff --git a/app/core/__pycache__/security.cpython-314.pyc b/app/core/__pycache__/security.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22c8c1f2fd07c0ee4d18086b4fe8501bc8814a3c Binary files /dev/null and b/app/core/__pycache__/security.cpython-314.pyc differ diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..f4e14c3ff40c1d49130d7b425fe65dae8bb16624 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str + REDIS_URL: str = 'redis://localhost:6379/0' + + SUPABASE_URL: str + SUPABASE_JWT_SECRET: str + SUPABASE_JWT_ALGORITHM: str = 'HS256' + + GOOGLE_MAPS_KEY: str + STRIPE_SECRET_KEY: str + STRIPE_PUBLISHABLE_KEY: str + + class Config: + env_file = '.env' + + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000000000000000000000000000000000000..be667e04cb159e8b366f65296c0a9b28fd5bc012 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base +from app.core.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) +Base = declarative_base() + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session diff --git a/app/deps.py b/app/deps.py new file mode 100644 index 0000000000000000000000000000000000000000..660e93cb9cee801459bd611c3d8b6871c61359f9 --- /dev/null +++ b/app/deps.py @@ -0,0 +1,77 @@ +import uuid +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +import requests +from sqlalchemy.exc import SQLAlchemyError +from app.core.config import settings +from app.core.database import get_db +from app.models.profile import Profile, UserRole + +oauth2 = OAuth2PasswordBearer(tokenUrl='supabase') + + +def verify_supabase_token(token: str = Depends(oauth2)) -> dict: + try: + header = jwt.get_unverified_header(token) + algorithm = header.get('alg') + if not algorithm: + raise JWTError('Missing token algorithm') + + if algorithm == settings.SUPABASE_JWT_ALGORITHM: + payload = jwt.decode( + token, + settings.SUPABASE_JWT_SECRET, + algorithms=[settings.SUPABASE_JWT_ALGORITHM], + audience='authenticated', + ) + else: + jwks_url = f'{settings.SUPABASE_URL.rstrip("/")}/auth/v1/.well-known/jwks.json' + response = requests.get(jwks_url, timeout=5) + response.raise_for_status() + keys = response.json().get('keys', []) + key = next((item for item in keys if item.get('kid') == header.get('kid')), None) + if not key: + raise JWTError('No matching JWKS key') + payload = jwt.decode( + token, + key, + algorithms=[algorithm], + audience='authenticated', + issuer=f'{settings.SUPABASE_URL.rstrip("/")}/auth/v1', + ) + uuid.UUID(payload['sub']) + return payload + except (JWTError, KeyError, ValueError, requests.RequestException): + raise HTTPException(401, 'Invalid token') + + +async def get_current_user(payload: dict = Depends(verify_supabase_token), db=Depends(get_db)) -> Profile: + user_id = uuid.UUID(payload['sub']) + + try: + profile = await db.get(Profile, user_id) + except SQLAlchemyError as exc: + raise HTTPException(503, 'Unable to load user profile') from exc + + if not profile: + metadata = payload.get('user_metadata') or {} + try: + role = UserRole(metadata.get('role')) + except ValueError: + role = UserRole.rider + + profile = Profile( + id=user_id, + full_name=metadata.get('full_name') or payload.get('email') or 'Rider', + role=role, + ) + db.add(profile) + try: + await db.commit() + await db.refresh(profile) + except SQLAlchemyError as exc: + await db.rollback() + raise HTTPException(503, 'Unable to create user profile') from exc + + return profile diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..62dd92af29131f86ae2168b95e206b960749c486 --- /dev/null +++ b/app/main.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.api.v1 import rides, drivers, payments, users +from app.websockets import routes as ws_routes + +app = FastAPI(title='RideShare API', version='1.0.0') + +app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_methods=['*'], + allow_headers=['*'], +) + +app.include_router(rides.router, prefix='/api/v1/rides', tags=['rides']) +app.include_router(drivers.router, prefix='/api/v1/drivers', tags=['drivers']) +app.include_router(payments.router, prefix='/api/v1/payments', tags=['payments']) +app.include_router(users.router, prefix='/api/v1/users', tags=['users']) +app.include_router(ws_routes.router) + + +@app.get('/health') +async def health(): + return {'status': 'ok'} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/__pycache__/__init__.cpython-314.pyc b/app/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e77dbca924b80d6f842d50275d67529aa3328884 Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/models/__pycache__/profile.cpython-314.pyc b/app/models/__pycache__/profile.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d23a0b13e89901383f2ce3d43c53a1af7762c40 Binary files /dev/null and b/app/models/__pycache__/profile.cpython-314.pyc differ diff --git a/app/models/__pycache__/ride.cpython-314.pyc b/app/models/__pycache__/ride.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9534d45048a5f385d875cda0f40d3e715fc829e5 Binary files /dev/null and b/app/models/__pycache__/ride.cpython-314.pyc differ diff --git a/app/models/__pycache__/user.cpython-314.pyc b/app/models/__pycache__/user.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7adacd82a4b1d4c7be28f681ea61da326eb62d0f Binary files /dev/null and b/app/models/__pycache__/user.cpython-314.pyc differ diff --git a/app/models/driver.py b/app/models/driver.py new file mode 100644 index 0000000000000000000000000000000000000000..8b93c29d52dd4c0c20d9b47815de374029482d4e --- /dev/null +++ b/app/models/driver.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, ForeignKey, Boolean, Float, String +from sqlalchemy.dialects.postgresql import UUID +from geoalchemy2 import Geography +from app.core.database import Base + + +class Driver(Base): + __tablename__ = 'drivers' + user_id = Column(UUID(as_uuid=True), ForeignKey('profiles.id', ondelete='CASCADE'), primary_key=True) + license_no = Column(String, nullable=False) + vehicle_make = Column(String) + vehicle_model = Column(String) + vehicle_plate = Column(String) + is_online = Column(Boolean, default=False) + rating = Column(Float, default=5.0) + current_location = Column(Geography('POINT', srid=4326)) diff --git a/app/models/payment.py b/app/models/payment.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/profile.py b/app/models/profile.py new file mode 100644 index 0000000000000000000000000000000000000000..0bf9de3d15314188d5191d82af41110bfeb31739 --- /dev/null +++ b/app/models/profile.py @@ -0,0 +1,19 @@ +import enum +from sqlalchemy import Column, String, DateTime, Enum, func +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class UserRole(str, enum.Enum): + rider = 'rider' + driver = 'driver' + + +class Profile(Base): + __tablename__ = 'profiles' + id = Column(UUID(as_uuid=True), primary_key=True) + full_name = Column(String, nullable=False) + role = Column(Enum(UserRole, name='user_role'), nullable=False) + stripe_customer_id = Column(String, nullable=True) + push_token = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/models/ride.py b/app/models/ride.py new file mode 100644 index 0000000000000000000000000000000000000000..47343717c89ca95e6b6d06a6df4f810ebbe8f3fd --- /dev/null +++ b/app/models/ride.py @@ -0,0 +1,35 @@ +import uuid +import enum +from sqlalchemy import Column, ForeignKey, Float, String, DateTime, Enum, func +from sqlalchemy.dialects.postgresql import UUID +from geoalchemy2 import Geography +from app.core.database import Base + + +class RideStatus(str, enum.Enum): + searching = 'searching' + accepted = 'accepted' + arriving = 'arriving' + in_progress = 'in_progress' + completed = 'completed' + cancelled = 'cancelled' + + +class Ride(Base): + __tablename__ = 'rides' + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + rider_id = Column(UUID(as_uuid=True), ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False) + driver_id = Column(UUID(as_uuid=True), ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True) + pickup = Column(Geography('POINT', srid=4326)) + dropoff = Column(Geography('POINT', srid=4326)) + pickup_address = Column(String) + dropoff_address = Column(String) + status = Column(Enum(RideStatus, name='ride_status'), default=RideStatus.searching, nullable=False) + fare = Column(Float) + distance_km = Column(Float) + duration_min = Column(Float) + payment_intent_id = Column(String, nullable=True) + vehicle_type = Column(String, default='car') + requested_at = Column(DateTime(timezone=True), server_default=func.now()) + started_at = Column(DateTime(timezone=True), nullable=True) + ended_at = Column(DateTime(timezone=True), nullable=True) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/__pycache__/__init__.cpython-314.pyc b/app/schemas/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca842600cac74720402b2bad15819ed01f1b98ba Binary files /dev/null and b/app/schemas/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/auth.cpython-314.pyc b/app/schemas/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..04e4955ba4f66eae6e13258fecfce587668370c8 Binary files /dev/null and b/app/schemas/__pycache__/auth.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/drivers.cpython-314.pyc b/app/schemas/__pycache__/drivers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df90177f8195c99060bad363e57f670c008c3c6d Binary files /dev/null and b/app/schemas/__pycache__/drivers.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/payments.cpython-314.pyc b/app/schemas/__pycache__/payments.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62cb53d9e357f3b1f20a97eae9f375649985075c Binary files /dev/null and b/app/schemas/__pycache__/payments.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/rides.cpython-314.pyc b/app/schemas/__pycache__/rides.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d288303f119ab565a04f8c7c3c6de595c5c4a2c6 Binary files /dev/null and b/app/schemas/__pycache__/rides.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/users.cpython-314.pyc b/app/schemas/__pycache__/users.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9ad750cb3ece061a656d1da0dfebb83a0330db5 Binary files /dev/null and b/app/schemas/__pycache__/users.cpython-314.pyc differ diff --git a/app/schemas/drivers.py b/app/schemas/drivers.py new file mode 100644 index 0000000000000000000000000000000000000000..7bbca34111004a1da52745755da7da2072095d73 --- /dev/null +++ b/app/schemas/drivers.py @@ -0,0 +1,8 @@ +import uuid +from pydantic import BaseModel + + +class LocationIn(BaseModel): + lat: float + lng: float + ride_id: uuid.UUID | None = None diff --git a/app/schemas/payments.py b/app/schemas/payments.py new file mode 100644 index 0000000000000000000000000000000000000000..64e8572852c16913ba7eaaa70b13b5d14410fa93 --- /dev/null +++ b/app/schemas/payments.py @@ -0,0 +1,28 @@ +import uuid +from pydantic import BaseModel + + +class InitRidePaymentIn(BaseModel): + ride_id: uuid.UUID + + +class InitRidePaymentOut(BaseModel): + payment_intent_id: str + client_secret: str + ephemeral_key: str + customer_id: str + publishable_key: str + + +class ConfirmRidePaymentIn(BaseModel): + ride_id: uuid.UUID + + +class TestChargeIn(BaseModel): + amount: float + currency: str = 'pkr' + + +class TestChargeOut(BaseModel): + payment_intent_id: str + client_secret: str diff --git a/app/schemas/rides.py b/app/schemas/rides.py new file mode 100644 index 0000000000000000000000000000000000000000..ddcec498dbdfac766057512da8d04edf97f22b33 --- /dev/null +++ b/app/schemas/rides.py @@ -0,0 +1,49 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, ConfigDict +from app.models.ride import RideStatus + + +class Coordinate(BaseModel): + lat: float + lng: float + + +class EstimateIn(BaseModel): + pickup: Coordinate + dropoff: Coordinate + vehicle_type: str = 'car' + + +class EstimateOut(BaseModel): + distance_km: float + duration_min: float + fare: float + vehicle_type: str = 'car' + + +class RideRequestIn(BaseModel): + pickup: Coordinate + dropoff: Coordinate + pickup_address: str + dropoff_address: str + fare: float + distance_km: float + duration_min: float + vehicle_type: str = 'car' + + +class RideOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + rider_id: uuid.UUID + driver_id: uuid.UUID | None = None + status: RideStatus + fare: float | None = None + distance_km: float | None = None + duration_min: float | None = None + pickup_address: str | None = None + dropoff_address: str | None = None + vehicle_type: str | None = None + requested_at: datetime diff --git a/app/schemas/users.py b/app/schemas/users.py new file mode 100644 index 0000000000000000000000000000000000000000..1da85c55693da2112a174be245776337800cf6e5 --- /dev/null +++ b/app/schemas/users.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class PushTokenIn(BaseModel): + token: str diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/__pycache__/__init__.cpython-314.pyc b/app/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdf748f9f8ffc6d17cb1126c5030ff8baecb0377 Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/services/__pycache__/maps.cpython-314.pyc b/app/services/__pycache__/maps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c5a4217f03b4cc60594439e9f11091019164d34 Binary files /dev/null and b/app/services/__pycache__/maps.cpython-314.pyc differ diff --git a/app/services/__pycache__/matching.cpython-314.pyc b/app/services/__pycache__/matching.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d9bd8c5b89831c5aed7133306a29759995ea2f1 Binary files /dev/null and b/app/services/__pycache__/matching.cpython-314.pyc differ diff --git a/app/services/__pycache__/offers.cpython-314.pyc b/app/services/__pycache__/offers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3de044d31bef94f560fb2d394d8eb21526041a69 Binary files /dev/null and b/app/services/__pycache__/offers.cpython-314.pyc differ diff --git a/app/services/__pycache__/pricing.cpython-314.pyc b/app/services/__pycache__/pricing.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b7bc350ec1034e5487b22243c371f338baf6dcb Binary files /dev/null and b/app/services/__pycache__/pricing.cpython-314.pyc differ diff --git a/app/services/__pycache__/redis_client.cpython-314.pyc b/app/services/__pycache__/redis_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24ecc8a79b3cf8e0363d754dcfccc7a9fbd4396f Binary files /dev/null and b/app/services/__pycache__/redis_client.cpython-314.pyc differ diff --git a/app/services/__pycache__/stripe_service.cpython-314.pyc b/app/services/__pycache__/stripe_service.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..302332df3fbe0ab793d2bcaa77a1d699acaefe3e Binary files /dev/null and b/app/services/__pycache__/stripe_service.cpython-314.pyc differ diff --git a/app/services/maps.py b/app/services/maps.py new file mode 100644 index 0000000000000000000000000000000000000000..e8f1f6388827c2c9aabcfb3f2c718d546d94d2ec --- /dev/null +++ b/app/services/maps.py @@ -0,0 +1,18 @@ +import math +from app.schemas.rides import Coordinate + + +async def google_directions(pickup: Coordinate, dropoff: Coordinate) -> tuple[float, float]: + # TODO: replace with real Google Directions API call (needs httpx + GOOGLE_MAPS_KEY). + distance_km = _haversine_km(pickup.lat, pickup.lng, dropoff.lat, dropoff.lng) + duration_min = (distance_km / 30.0) * 60.0 + return round(distance_km, 2), round(duration_min, 1) + + +def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + R = 6371.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lng2 - lng1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) diff --git a/app/services/matching.py b/app/services/matching.py new file mode 100644 index 0000000000000000000000000000000000000000..b264508d081082d68a257b7c021633b51ecf5905 --- /dev/null +++ b/app/services/matching.py @@ -0,0 +1,22 @@ +import uuid +from fastapi import HTTPException +from app.schemas.rides import Coordinate +from app.services.offers import offer_to_driver +from app.services.redis_client import r + + +async def find_nearest_driver(ride_id: uuid.UUID, pickup: Coordinate) -> str: + nearby = await r.geosearch( + 'drivers:online', + longitude=pickup.lng, + latitude=pickup.lat, + radius=5, + unit='km', + sort='ASC', + count=10, + ) + for driver_id in nearby: + accepted = await offer_to_driver(driver_id, ride_id, timeout=15) + if accepted: + return driver_id + raise HTTPException(404, 'No drivers available') diff --git a/app/services/notifications.py b/app/services/notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..affd8d44913797857e0bb25ad30a2c6b63816bf6 --- /dev/null +++ b/app/services/notifications.py @@ -0,0 +1,9 @@ +import httpx + +EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send' + + +async def send_push(token: str, title: str, body: str, data: dict | None = None) -> None: + payload = {'to': token, 'title': title, 'body': body, 'data': data or {}} + async with httpx.AsyncClient(timeout=10) as client: + await client.post(EXPO_PUSH_URL, json=payload) diff --git a/app/services/offers.py b/app/services/offers.py new file mode 100644 index 0000000000000000000000000000000000000000..3294ee2be0226d9eead31f821c06c6b788309fb8 --- /dev/null +++ b/app/services/offers.py @@ -0,0 +1,26 @@ +import asyncio +import uuid +from app.websockets.manager import manager + +_pending: dict[str, asyncio.Future] = {} + + +async def offer_to_driver(driver_id: uuid.UUID, ride_id: uuid.UUID, timeout: int = 15) -> bool: + key = str(ride_id) + fut: asyncio.Future = asyncio.get_event_loop().create_future() + _pending[key] = fut + try: + await manager.send(str(driver_id), {'type': 'ride_offer', 'ride_id': key}) + try: + response = await asyncio.wait_for(fut, timeout=timeout) + return response == 'accept' + except asyncio.TimeoutError: + return False + finally: + _pending.pop(key, None) + + +def resolve_offer(ride_id: str, response: str) -> None: + fut = _pending.get(ride_id) + if fut and not fut.done(): + fut.set_result(response) diff --git a/app/services/pricing.py b/app/services/pricing.py new file mode 100644 index 0000000000000000000000000000000000000000..95672bf8816a1f7784ba588fb6c5a8fb2bdf2fca --- /dev/null +++ b/app/services/pricing.py @@ -0,0 +1,20 @@ +BASE_FARE = 50.0 +PER_KM = 25.0 +PER_MIN = 5.0 +# Stripe requires a charge to convert to at least USD 0.50, which is roughly +# Rs 140 at current rates. We floor at Rs 200 to give a buffer for FX drift. +MIN_FARE_PKR = 200.0 + +VEHICLE_MULTIPLIERS: dict[str, float] = { + 'bike': 0.45, + 'rickshaw': 0.65, + 'car': 1.0, + 'van': 1.5, + 'bus': 0.35, +} + + +def estimate_fare(distance_km: float, duration_min: float, vehicle_type: str = 'car') -> float: + multiplier = VEHICLE_MULTIPLIERS.get(vehicle_type, 1.0) + base = (BASE_FARE + (distance_km * PER_KM) + (duration_min * PER_MIN)) * multiplier + return round(max(MIN_FARE_PKR, base), 2) diff --git a/app/services/redis_client.py b/app/services/redis_client.py new file mode 100644 index 0000000000000000000000000000000000000000..804ae2c735ae9cd8f73cbe053dbed9b3d28ab7db --- /dev/null +++ b/app/services/redis_client.py @@ -0,0 +1,4 @@ +import redis.asyncio as redis +from app.core.config import settings + +r = redis.from_url(settings.REDIS_URL, decode_responses=True) diff --git a/app/services/stripe_service.py b/app/services/stripe_service.py new file mode 100644 index 0000000000000000000000000000000000000000..91ad6cb916632eb9f396b717d550a7a875e8be42 --- /dev/null +++ b/app/services/stripe_service.py @@ -0,0 +1,36 @@ +import stripe +from app.core.config import settings +from app.models.profile import Profile + +stripe.api_key = settings.STRIPE_SECRET_KEY + +STRIPE_API_VERSION = '2024-06-20' + + +def ensure_customer(profile: Profile) -> str: + if profile.stripe_customer_id: + return profile.stripe_customer_id + customer = stripe.Customer.create( + name=profile.full_name, + metadata={'profile_id': str(profile.id)}, + ) + profile.stripe_customer_id = customer.id + return customer.id + + +def create_ephemeral_key(customer_id: str) -> str: + key = stripe.EphemeralKey.create( + customer=customer_id, + stripe_version=STRIPE_API_VERSION, + ) + return key.secret + + +def create_payment_intent(amount: float, customer_id: str, ride_id: str) -> dict: + return stripe.PaymentIntent.create( + amount=int(amount * 100), + currency='pkr', + customer=customer_id, + automatic_payment_methods={'enabled': True}, + metadata={'ride_id': ride_id}, + ) diff --git a/app/websockets/__init__.py b/app/websockets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/websockets/__pycache__/__init__.cpython-314.pyc b/app/websockets/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be0d9758b3a4ff71f3b0442ce5724624a883da51 Binary files /dev/null and b/app/websockets/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/websockets/__pycache__/manager.cpython-314.pyc b/app/websockets/__pycache__/manager.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d210d6f9d275abf1fe8cc4d0c3deee7b689004fd Binary files /dev/null and b/app/websockets/__pycache__/manager.cpython-314.pyc differ diff --git a/app/websockets/__pycache__/routes.cpython-314.pyc b/app/websockets/__pycache__/routes.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91b710571f5b3f3c1461c0dcda7b6a9ac975cdf4 Binary files /dev/null and b/app/websockets/__pycache__/routes.cpython-314.pyc differ diff --git a/app/websockets/handlers.py b/app/websockets/handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/websockets/manager.py b/app/websockets/manager.py new file mode 100644 index 0000000000000000000000000000000000000000..a1451bcb02b32100c948f79b06bc4bcccd229eba --- /dev/null +++ b/app/websockets/manager.py @@ -0,0 +1,20 @@ +from fastapi import WebSocket +from typing import Dict + +class ConnectionManager: + def __init__(self): + self.active: Dict[str, WebSocket] = {} + + async def connect(self, ws: WebSocket, user_id: str): + await ws.accept() + self.active[user_id] = ws + + def disconnect(self, user_id: str): + self.active.pop(user_id, None) + + async def send(self, user_id: str, message: dict): + ws = self.active.get(user_id) + if ws: + await ws.send_json(message) + +manager = ConnectionManager() diff --git a/app/websockets/routes.py b/app/websockets/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..426056e90ee868f20355e1ad93507a6e22fae5af --- /dev/null +++ b/app/websockets/routes.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from jose import jwt, JWTError + +from app.core.config import settings +from app.services.offers import resolve_offer +from app.websockets.manager import manager + +router = APIRouter() + + +@router.websocket('/ws/{user_id}') +async def websocket_endpoint(ws: WebSocket, user_id: str, token: str): + try: + payload = jwt.decode( + token, + settings.SUPABASE_JWT_SECRET, + algorithms=[settings.SUPABASE_JWT_ALGORITHM], + audience='authenticated', + ) + if payload.get('sub') != user_id: + await ws.close(code=4401) + return + except (JWTError, KeyError): + await ws.close(code=4401) + return + + await manager.connect(ws, user_id) + try: + while True: + data = await ws.receive_json() + msg_type = data.get('type') + if msg_type == 'ride_offer_response': + # Driver accepted/rejected an offer. + resolve_offer(data['ride_id'], data.get('response', 'reject')) + # Other types (chat, etc.) can be added here. + except WebSocketDisconnect: + manager.disconnect(user_id) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..5faa8cbb72e75094991916be20e7898386f7bb9c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,47 @@ +alembic==1.18.4 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +asyncpg==0.31.0 +bcrypt==5.0.0 +certifi==2026.4.22 +cffi==2.0.0 +charset-normalizer==3.4.7 +click==8.3.3 +cryptography==48.0.0 +dnspython==2.8.0 +ecdsa==0.19.2 +email-validator==2.3.0 +fastapi==0.136.1 +GeoAlchemy2==0.19.0 +greenlet>=3.2.4 +h11==0.16.0 +httptools==0.7.1 +idna==3.13 +Mako==1.3.12 +MarkupSafe==3.0.3 +packaging==26.2 +passlib==1.7.4 +pyasn1==0.6.3 +pycparser==3.0 +pydantic==2.13.4 +pydantic-settings==2.14.1 +pydantic_core==2.46.4 +python-dotenv==1.2.2 +python-jose==3.5.0 +python-multipart==0.0.27 +PyYAML==6.0.3 +redis==7.4.0 +requests==2.33.1 +rsa==4.9.1 +six==1.17.0 +SQLAlchemy==2.0.49 +starlette==1.0.0 +stripe==15.1.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.7.0 +uvicorn==0.46.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==16.0