Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
**Architecture:** FastAPI (Python 3.13+) backend + React TypeScript frontend
**Stack:** SQLite + Alembic, Native hot-reload, E2E testing with Playwright
**Design System:** Monochrome palette (sand/ink/charcoal) with subtle slate blue accents
**URLs:** Frontend http://localhost:3001, Backend http://localhost:8001/docs
**URLs:** Frontend http://localhost:3001, Backend http://localhost:8001/docs
**Features:** Multi-currency (USD/GBP/EUR/AUD/MXN/JPY), live FX rates, delivery options

## Essential Commands

Expand Down Expand Up @@ -160,6 +161,7 @@ def process_payment(payment_data: dict) -> Result:
### Frontend Architecture
- **Components:** Functional components only, TypeScript interfaces
- **State Management:** Context API for global state, local state with useState/useReducer
- **Currency System:** CurrencyContext manages FX rates, conversion, formatting (6 currencies)
- **Forms:** Formik + Yup for validation
- **UI:** Chakra UI components
- **Data Flow:** API calls → context state → component props
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,26 @@ Follow the [Quick Start](#quick-start) guide to get started. Once the front and

See the [DEMO.md](DEMO.md) for more information about how to effectively use this repo for an array of different demo purposes ranging from issue to PR to advanced feature adds.

## Features

- **Full-stack E-commerce:** Product catalog, cart, delivery options
- **Multi-Currency Support:** USD, GBP, EUR, AUD, MXN, JPY with live FX rates
- **Image Management:** BLOB storage with SQLite, efficient retrieval
- **Delivery Options:** Configurable shipping with cost calculations
- **Modern Stack:** FastAPI + React TypeScript + SQLite
- **Test-Driven:** 100% coverage requirement, Playwright E2E tests

## Project Structure

```
.
├── .github/ # GitHub workflows and CI configuration
├── backend/ # FastAPI backend
│ ├── app/ # Application source code
│ │ ├── currency.py # FX rates service and models
│ │ ├── models.py # SQLModel database models
│ │ ├── schemas.py # Pydantic request/response schemas
│ │ └── main.py # FastAPI routes and app
│ ├── tests/ # Backend tests
│ ├── alembic/ # Database migrations
│ ├── pyproject.toml # Python dependencies (managed by uv)
Expand All @@ -30,8 +43,11 @@ See the [DEMO.md](DEMO.md) for more information about how to effectively use thi
│ ├── src/ # React components and pages
│ │ ├── api/ # API client and types
│ │ ├── components/ # Reusable UI components
│ │ │ └── CurrencySelector.tsx # Currency picker
│ │ ├── context/ # React Context providers
│ │ │ └── CurrencyContext.tsx # Currency state management
│ │ ├── hooks/ # Custom React hooks
│ │ │ └── useCurrency.ts # Currency hook
│ │ ├── mockDB/ # Mock data for development
│ │ ├── pages/ # Page-level components
│ │ └── theme/ # Chakra UI theme configuration
Expand Down
33 changes: 24 additions & 9 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,21 @@ def get_mock_product(overrides: dict = None) -> Product:
```
backend/
├── app/
│ ├── models/ # SQLModel database models
│ ├── schemas/ # Pydantic request/response models
│ ├── routers/ # FastAPI route handlers
│ ├── services/ # Business logic layer
│ ├── repositories/ # Database access layer
│ ├── dependencies.py # FastAPI dependencies
│ └── database.py # Database connection setup
│ ├── currency.py # FX rates service, models, schemas
│ ├── models.py # SQLModel database models (Product, Category, DeliveryOption, FxRates)
│ ├── schemas.py # Pydantic request/response models
│ ├── crud.py # Database operations
│ ├── db.py # Database connection setup
│ ├── main.py # FastAPI routes and app entry point
│ └── seed.py # Database seeding script
├── tests/
│ ├── test_products.py # Product API tests
│ ├── test_orders.py # Order API tests
│ ├── api/
│ │ ├── test_products.py # Product API tests
│ │ ├── test_currency.py # Currency/FX rates tests
│ │ ├── test_categories.py # Category API tests
│ │ ├── test_delivery_options.py # Delivery options tests
│ │ └── test_images.py # Image handling tests
│ ├── factories.py # Test data factories
│ └── conftest.py # Pytest fixtures
├── alembic/
│ └── versions/ # Database migrations
Expand Down Expand Up @@ -210,6 +215,16 @@ class Order(SQLModel, table=True):
- `PUT /products/{id}` - Update product (full)
- `PATCH /products/{id}` - Partial update
- `DELETE /products/{id}` - Delete product
- `GET /fx/rates` - Get currency exchange rates

### Currency/FX Rates
- **Endpoint:** `GET /fx/rates`
- **Base Currency:** USD (all prices stored in USD)
- **Supported Currencies:** USD, GBP, EUR, AUD, MXN, JPY
- **Caching:** 6-hour TTL with 3-tier caching (memory → DB → fallback)
- **Provider:** frankfurter.app (free, no auth required)
- **Response:** Includes rates, fetched_at, ttl_seconds, stale flag
- **Graceful Degradation:** Serves stale rates when provider unavailable

### Response Format
```python
Expand Down
35 changes: 35 additions & 0 deletions backend/alembic/versions/50f63877083c_add_fx_rates_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""add_fx_rates_table

Revision ID: 50f63877083c
Revises: 94404b2e4890
Create Date: 2025-10-03 16:21:30.722478

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '50f63877083c'
down_revision: Union[str, Sequence[str], None] = '94404b2e4890'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
'fx_rates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('base', sa.String(), nullable=False),
sa.Column('rates', sa.JSON(), nullable=False),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('fx_rates')
126 changes: 126 additions & 0 deletions backend/app/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from sqlmodel import SQLModel, Field, Session, select
from sqlalchemy import Column, JSON
from typing import Optional, Literal
from datetime import datetime, UTC
from pydantic import BaseModel
import httpx


CurrencyCode = Literal["USD", "GBP", "EUR", "AUD", "MXN", "JPY"]

TTL_SECONDS = 21600


class FxRates(SQLModel, table=True):
__tablename__ = "fx_rates"

id: Optional[int] = Field(default=None, primary_key=True)
base: str = Field(default="USD")
rates: dict = Field(sa_column=Column(JSON))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential bug: Using default_factory for fetched_at can cause issues when loading existing records from the database. SQLModel might re-evaluate this factory when hydrating objects, potentially overwriting the actual stored timestamp. Consider removing the default and setting this explicitly when creating new records, or use SQLAlchemy's server_default if you want database-level defaults.

fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))


class RatesResponse(BaseModel):
base: str
rates: dict[str, float]
fetched_at: datetime
ttl_seconds: int
stale: bool


class FxService:
def __init__(self, session: Session):
self.session = session
self._cache: Optional[dict] = None
self._cache_fetched_at: Optional[datetime] = None

def get_rates(self) -> RatesResponse:
now = datetime.now(UTC)

if self._is_cache_valid(now) and self._cache is not None and self._cache_fetched_at is not None:
return self._build_response(
self._cache,
self._cache_fetched_at,
stale=False
)

try:
fresh_rates = self.fetch_from_provider()
self._cache = fresh_rates
self._cache_fetched_at = now

self._persist_to_db(fresh_rates, now)

return self._build_response(fresh_rates, now, stale=False)
except Exception:
db_rates = self._get_from_db()
if db_rates:
self._cache = db_rates["rates"]
self._cache_fetched_at = db_rates["fetched_at"]
return self._build_response(
db_rates["rates"],
db_rates["fetched_at"],
stale=True
)

return self._build_response(
{},
now,
stale=True
)

def _is_cache_valid(self, now: datetime) -> bool:
if self._cache is None or self._cache_fetched_at is None:
return False

age = (now - self._cache_fetched_at).total_seconds()
return age < TTL_SECONDS

def fetch_from_provider(self) -> dict:
url = "https://api.frankfurter.app/latest"
params = {
"from": "USD",
"to": "GBP,EUR,AUD,MXN,JPY"
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling: response.raise_for_status() will raise an exception for HTTP errors, but response.json() can also fail if the response body is not valid JSON. Consider wrapping the JSON parsing in a try-except block or validating the response structure before accessing nested fields.

response = httpx.get(url, params=params, timeout=10.0)
response.raise_for_status()

data = response.json()
return data["rates"]

def _persist_to_db(self, rates: dict, fetched_at: datetime) -> None:
fx_rates = FxRates(
base="USD",
rates=rates,
fetched_at=fetched_at
)
self.session.add(fx_rates)
self.session.commit()

def _get_from_db(self) -> Optional[dict]:
stmt = select(FxRates).order_by(FxRates.fetched_at.desc()).limit(1) # type: ignore[attr-defined]
result = self.session.exec(stmt).first()

if result:
return {
"rates": result.rates,
"fetched_at": result.fetched_at
}
return None

def _build_response(
self,
rates: dict,
fetched_at: datetime,
stale: bool
) -> RatesResponse:
all_rates = {"USD": 1.0, **rates}

return RatesResponse(
base="USD",
rates=all_rates,
fetched_at=fetched_at,
ttl_seconds=TTL_SECONDS,
stale=stale
)
10 changes: 6 additions & 4 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
engine = create_engine(
DATABASE_URL,
connect_args={
"check_same_thread": False, # SQLite only
"check_same_thread": False,
}
)

Expand All @@ -15,11 +15,13 @@ def get_session():
yield session

def create_db_and_tables():
from .models import Product, Category, DeliveryOption, ProductDeliveryLink # noqa: F401
from .currency import FxRates # noqa: F401

SQLModel.metadata.create_all(engine)

# Enable WAL mode for better performance with BLOBs
with engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL;"))
conn.execute(text("PRAGMA cache_size=10000;")) # 10MB cache
conn.execute(text("PRAGMA synchronous=NORMAL;")) # better write throughput
conn.execute(text("PRAGMA cache_size=10000;"))
conn.execute(text("PRAGMA synchronous=NORMAL;"))
conn.commit()
6 changes: 6 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from . import crud
from .models import Product, DeliveryOption, Category, ProductDeliveryLink
from .currency import FxService, RatesResponse

def calculate_delivery_summary(delivery_options: List[DeliveryOption]) -> Optional[DeliverySummary]:
"""Calculate delivery summary from a list of delivery options"""
Expand Down Expand Up @@ -427,6 +428,11 @@ def get_product_image(
}
)

@app.get("/fx/rates", response_model=RatesResponse)
def get_fx_rates(session: Session = Depends(get_session)):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance issue: Creating a new FxService instance on every request defeats the purpose of the in-memory cache (_cache and _cache_fetched_at). The cache will never be reused across requests. Consider making FxService a singleton or using FastAPI's dependency injection with a cached instance.

service = FxService(session)
return service.get_rates()

if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
Binary file modified backend/store.db
Binary file not shown.
Loading
Loading