feat: Initialize mycopunk-swag-store e-commerce platform

Full-stack e-commerce platform for mycopunk merchandise:

Backend (FastAPI):
- Design service reading from mycopunk-swag repo
- Cart, checkout, and order management
- Stripe payment integration
- POD webhook handlers (Prodigi, Printful)
- Admin API with JWT auth
- PostgreSQL with Alembic migrations

Frontend (Next.js 15):
- Product catalog with SSR
- Shopping cart with localStorage persistence
- Stripe checkout redirect flow
- Tailwind CSS + shadcn/ui theming

Infrastructure:
- Docker Compose with PostgreSQL, Redis
- Traefik labels for swag.mycofi.earth
- Multi-stage Dockerfiles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-29 16:59:45 +00:00
commit b924e41ce8
58 changed files with 3559 additions and 0 deletions

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
# Database
DB_PASSWORD=change_me_in_production
# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# POD Providers
PRODIGI_API_KEY=xxx
PRINTFUL_API_TOKEN=xxx
POD_SANDBOX_MODE=true
# Auth
JWT_SECRET=generate_a_strong_secret_here
# App
CORS_ORIGINS=http://localhost:3000
# Email (Resend)
RESEND_API_KEY=re_xxx
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:8000/api
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

67
.gitignore vendored Normal file
View File

@ -0,0 +1,67 @@
# Environment
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.venv/
venv/
ENV/
# Node
node_modules/
.next/
out/
.pnpm-store/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Database
*.db
*.sqlite3
# Test
.coverage
htmlcov/
.pytest_cache/
.tox/
# Build
*.pyc
.cache/
# Docker
docker-compose.override.yml

114
CLAUDE.md Normal file
View File

@ -0,0 +1,114 @@
# Mycopunk Swag Store - AI Assistant Context
## Project Overview
E-commerce platform for mycopunk merchandise (stickers, shirts, prints) with Stripe payments and print-on-demand fulfillment via Printful and Prodigi.
## Architecture
- **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS
- **Backend**: FastAPI, SQLAlchemy, Alembic
- **Database**: PostgreSQL
- **Payments**: Stripe Checkout (redirect flow)
- **Fulfillment**: Printful (apparel), Prodigi (stickers/prints)
- **Deployment**: Docker on Netcup RS 8000, Traefik routing
## Key Directories
| Directory | Purpose |
|-----------|---------|
| `backend/app/api/` | FastAPI route handlers |
| `backend/app/models/` | SQLAlchemy ORM models |
| `backend/app/schemas/` | Pydantic request/response schemas |
| `backend/app/services/` | Business logic (stripe, pod, orders) |
| `backend/app/pod/` | POD provider clients (from mycopunk-swag) |
| `frontend/app/` | Next.js App Router pages |
| `frontend/components/` | React components |
## Design Source
Designs are read from the mycopunk-swag repo at runtime:
- **Local**: `/home/jeffe/Github/mycopunk-swag/designs/`
- **Docker**: Volume mounted from `/opt/mycopunk-swag/designs`
Each design has a `metadata.yaml` with name, description, products, variants, and pricing.
## API Endpoints
### Public
- `GET /api/designs` - List active designs
- `GET /api/designs/{slug}` - Get design details
- `GET /api/designs/{slug}/image` - Serve design image
- `GET /api/products` - List products with variants
- `POST /api/cart` - Create cart
- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations
- `POST /api/checkout/session` - Create Stripe checkout
- `GET /api/orders/{id}` - Order status (requires email)
### Webhooks
- `POST /api/webhooks/stripe` - Stripe payment events
- `POST /api/webhooks/prodigi` - Prodigi fulfillment updates
- `POST /api/webhooks/printful` - Printful fulfillment updates
### Admin (JWT required)
- `POST /api/admin/auth/login` - Admin login
- `GET /api/admin/orders` - List orders
- `GET /api/admin/analytics/*` - Sales metrics
## Order Flow
1. Customer adds items to cart (cart_id in localStorage)
2. Checkout creates Stripe session, redirects to Stripe
3. Stripe webhook fires on payment success
4. Backend creates order, submits to POD provider
5. POD webhook updates order status
6. Customer receives email notifications
## Common Tasks
### Run locally
```bash
docker compose up -d
```
### Run migrations
```bash
cd backend
alembic upgrade head
```
### Add a new API endpoint
1. Create route in `backend/app/api/`
2. Add Pydantic schemas in `backend/app/schemas/`
3. Register router in `backend/app/main.py`
### Add a new component
```bash
cd frontend
npx shadcn@latest add button # or other component
```
## Environment Variables
See `.env.example` for all required variables.
## Testing
```bash
# Backend
cd backend
pytest
# Frontend
cd frontend
pnpm test
```
## Deployment
Push to Gitea triggers webhook → auto-deploy on Netcup.
Manual deploy:
```bash
ssh netcup "cd /opt/mycopunk-swag-store && git pull && docker compose up -d --build"
```

105
README.md Normal file
View File

@ -0,0 +1,105 @@
# Mycopunk Swag Store
E-commerce platform for mycopunk merchandise at **swag.mycofi.earth**
## Stack
- **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS
- **Backend**: FastAPI + SQLAlchemy + Alembic
- **Database**: PostgreSQL
- **Payments**: Stripe Checkout
- **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints)
## Architecture
```
swag.mycofi.earth
Cloudflare Tunnel → Traefik
│ │
▼ ▼
Next.js (3000) FastAPI (8000)
┌───────────────┼───────────────┐
▼ ▼ ▼
PostgreSQL Stripe POD APIs
```
## Development
### Prerequisites
- Docker & Docker Compose
- Python 3.12+
- Node.js 20+
- pnpm
### Quick Start
```bash
# Clone and setup
cd /home/jeffe/Github/mycopunk-swag-store
cp .env.example .env
# Edit .env with your API keys
# Start services
docker compose up -d
# Backend available at http://localhost:8000
# Frontend available at http://localhost:3000
```
### Local Development (without Docker)
```bash
# Backend
cd backend
pip install -e .
uvicorn app.main:app --reload
# Frontend
cd frontend
pnpm install
pnpm dev
```
## Project Structure
```
mycopunk-swag-store/
├── backend/ # FastAPI Python backend
│ ├── app/
│ │ ├── api/ # Route handlers
│ │ ├── models/ # SQLAlchemy ORM
│ │ ├── schemas/ # Pydantic models
│ │ ├── services/ # Business logic
│ │ └── pod/ # POD provider clients
│ └── alembic/ # Database migrations
└── frontend/ # Next.js 15 frontend
├── app/ # App Router pages
├── components/ # React components
└── lib/ # Utilities
```
## Environment Variables
See `.env.example` for required configuration.
## Deployment
Deployed on Netcup RS 8000 via Docker Compose with Traefik reverse proxy.
```bash
# On server
cd /opt/mycopunk-swag-store
git pull
docker compose up -d --build
```
## Links
- [Stripe Dashboard](https://dashboard.stripe.com)
- [Printful API Docs](https://developers.printful.com/docs/)
- [Prodigi API Docs](https://www.prodigi.com/print-api/docs/)

47
backend/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Stage 2: Runtime
FROM python:3.12-slim AS runtime
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 --ingroup appgroup appuser
# Install wheels
COPY --from=builder /app/wheels /wheels
RUN pip install --no-cache /wheels/*
# Copy application code
COPY --chown=appuser:appgroup app/ ./app/
COPY --chown=appuser:appgroup alembic/ ./alembic/
COPY --chown=appuser:appgroup alembic.ini ./
# Create directories for mounted volumes
RUN mkdir -p /app/designs /app/config && \
chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

42
backend/alembic.ini Normal file
View File

@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://swag:devpassword@localhost:5432/swag
[post_write_hooks]
[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

83
backend/alembic/env.py Normal file
View File

@ -0,0 +1,83 @@
"""Alembic environment configuration."""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import models and base
from app.database import Base
from app.models import * # noqa: F401, F403
# this is the Alembic Config object
config = context.config
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Model metadata for autogenerate
target_metadata = Base.metadata
def get_url():
"""Get database URL from environment or config."""
import os
return os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url"))
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in async mode."""
url = get_url()
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = url
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,142 @@
"""Initial database schema
Revision ID: 001_initial
Revises:
Create Date: 2026-01-29
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Customers table
op.create_table(
"customers",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("stripe_customer_id", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_customers_email", "customers", ["email"], unique=True)
# Carts table
op.create_table(
"carts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("customer_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.PrimaryKeyConstraint("id"),
)
# Cart items table
op.create_table(
"cart_items",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("cart_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("product_slug", sa.String(100), nullable=False),
sa.Column("product_name", sa.String(255), nullable=False),
sa.Column("variant", sa.String(50), nullable=True),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["cart_id"], ["carts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
# Orders table
op.create_table(
"orders",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("customer_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("stripe_session_id", sa.String(255), nullable=True),
sa.Column("stripe_payment_intent_id", sa.String(255), nullable=True),
sa.Column("status", sa.String(50), nullable=True),
sa.Column("shipping_name", sa.String(255), nullable=True),
sa.Column("shipping_email", sa.String(255), nullable=True),
sa.Column("shipping_address_line1", sa.String(255), nullable=True),
sa.Column("shipping_address_line2", sa.String(255), nullable=True),
sa.Column("shipping_city", sa.String(100), nullable=True),
sa.Column("shipping_state", sa.String(100), nullable=True),
sa.Column("shipping_postal_code", sa.String(20), nullable=True),
sa.Column("shipping_country", sa.String(2), nullable=True),
sa.Column("subtotal", sa.Numeric(10, 2), nullable=True),
sa.Column("shipping_cost", sa.Numeric(10, 2), nullable=True),
sa.Column("tax", sa.Numeric(10, 2), nullable=True),
sa.Column("total", sa.Numeric(10, 2), nullable=True),
sa.Column("currency", sa.String(3), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("shipped_at", sa.DateTime(), nullable=True),
sa.Column("delivered_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.PrimaryKeyConstraint("id"),
)
# Order items table
op.create_table(
"order_items",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("order_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("product_slug", sa.String(100), nullable=False),
sa.Column("product_name", sa.String(255), nullable=False),
sa.Column("variant", sa.String(50), nullable=True),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
sa.Column("pod_provider", sa.String(50), nullable=True),
sa.Column("pod_order_id", sa.String(255), nullable=True),
sa.Column("pod_status", sa.String(50), nullable=True),
sa.Column("pod_tracking_number", sa.String(100), nullable=True),
sa.Column("pod_tracking_url", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
# Admin users table
op.create_table(
"admin_users",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_admin_users_email", "admin_users", ["email"], unique=True)
# Product overrides table
op.create_table(
"product_overrides",
sa.Column("slug", sa.String(100), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("price_override", sa.Numeric(10, 2), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("slug"),
)
def downgrade() -> None:
op.drop_table("product_overrides")
op.drop_index("ix_admin_users_email", table_name="admin_users")
op.drop_table("admin_users")
op.drop_table("order_items")
op.drop_table("orders")
op.drop_table("cart_items")
op.drop_table("carts")
op.drop_index("ix_customers_email", table_name="customers")
op.drop_table("customers")

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
# Mycopunk Swag Store Backend

View File

@ -0,0 +1,3 @@
"""API routes."""
from app.api import designs, products, cart, checkout, orders, webhooks, health

View File

@ -0,0 +1,12 @@
"""Admin API routes."""
from fastapi import APIRouter
from app.api.admin import auth, orders, analytics, products
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["admin-auth"])
router.include_router(orders.router, prefix="/orders", tags=["admin-orders"])
router.include_router(analytics.router, prefix="/analytics", tags=["admin-analytics"])
router.include_router(products.router, prefix="/products", tags=["admin-products"])

View File

@ -0,0 +1,39 @@
"""Admin analytics endpoints."""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.analytics_service import AnalyticsService
from app.services.auth_service import get_current_admin
router = APIRouter()
def get_analytics_service(db: AsyncSession = Depends(get_db)) -> AnalyticsService:
return AnalyticsService(db)
@router.get("/sales")
async def get_sales_analytics(
days: int = Query(default=30, le=365),
service: AnalyticsService = Depends(get_analytics_service),
_admin=Depends(get_current_admin),
):
"""Get sales analytics (admin only)."""
start_date = datetime.utcnow() - timedelta(days=days)
return await service.get_sales_summary(start_date)
@router.get("/products")
async def get_product_analytics(
days: int = Query(default=30, le=365),
limit: int = Query(default=10, le=50),
service: AnalyticsService = Depends(get_analytics_service),
_admin=Depends(get_current_admin),
):
"""Get product performance analytics (admin only)."""
start_date = datetime.utcnow() - timedelta(days=days)
return await service.get_product_performance(start_date, limit)

View File

@ -0,0 +1,36 @@
"""Admin authentication endpoints."""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.auth_service import AuthService
router = APIRouter()
class LoginRequest(BaseModel):
email: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
def get_auth_service(db: AsyncSession = Depends(get_db)) -> AuthService:
return AuthService(db)
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
auth_service: AuthService = Depends(get_auth_service),
):
"""Admin login."""
token = await auth_service.authenticate(request.email, request.password)
if not token:
raise HTTPException(status_code=401, detail="Invalid credentials")
return LoginResponse(access_token=token)

View File

@ -0,0 +1,57 @@
"""Admin order management endpoints."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.order import OrderResponse, OrderStatus
from app.services.order_service import OrderService
from app.services.auth_service import get_current_admin
router = APIRouter()
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(db)
@router.get("", response_model=list[OrderResponse])
async def list_orders(
status: OrderStatus | None = None,
limit: int = Query(default=50, le=100),
offset: int = Query(default=0, ge=0),
service: OrderService = Depends(get_order_service),
_admin=Depends(get_current_admin),
):
"""List all orders (admin only)."""
orders = await service.list_orders(status=status, limit=limit, offset=offset)
return orders
@router.get("/{order_id}", response_model=OrderResponse)
async def get_order(
order_id: UUID,
service: OrderService = Depends(get_order_service),
_admin=Depends(get_current_admin),
):
"""Get order details (admin only)."""
order = await service.get_order_by_id(order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.put("/{order_id}/status")
async def update_order_status(
order_id: UUID,
status: OrderStatus,
service: OrderService = Depends(get_order_service),
_admin=Depends(get_current_admin),
):
"""Update order status (admin only)."""
order = await service.update_status(order_id, status)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return {"status": "updated", "order_id": order_id, "new_status": status}

View File

@ -0,0 +1,57 @@
"""Admin product management endpoints."""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.design_service import DesignService
from app.services.auth_service import get_current_admin
router = APIRouter()
class ProductOverrideRequest(BaseModel):
is_active: bool | None = None
price_override: float | None = None
def get_design_service() -> DesignService:
return DesignService()
@router.put("/{slug}/override")
async def update_product_override(
slug: str,
override: ProductOverrideRequest,
db: AsyncSession = Depends(get_db),
design_service: DesignService = Depends(get_design_service),
_admin=Depends(get_current_admin),
):
"""Update product visibility or price override (admin only)."""
# Verify product exists
product = await design_service.get_product(slug)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# Update override in database
await design_service.set_product_override(
db=db,
slug=slug,
is_active=override.is_active,
price_override=override.price_override,
)
return {"status": "updated", "slug": slug}
@router.post("/sync")
async def sync_designs(
design_service: DesignService = Depends(get_design_service),
_admin=Depends(get_current_admin),
):
"""Force sync designs from mycopunk-swag repo (admin only)."""
# Clear any caches and reload
design_service.clear_cache()
designs = await design_service.list_designs()
return {"status": "synced", "count": len(designs)}

83
backend/app/api/cart.py Normal file
View File

@ -0,0 +1,83 @@
"""Cart API endpoints."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.cart import (
CartCreate,
CartResponse,
CartItemCreate,
CartItemUpdate,
CartItemResponse,
)
from app.services.cart_service import CartService
router = APIRouter()
def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService:
return CartService(db)
@router.post("", response_model=CartResponse)
async def create_cart(
service: CartService = Depends(get_cart_service),
):
"""Create a new shopping cart."""
cart = await service.create_cart()
return cart
@router.get("/{cart_id}", response_model=CartResponse)
async def get_cart(
cart_id: UUID,
service: CartService = Depends(get_cart_service),
):
"""Get cart by ID."""
cart = await service.get_cart(cart_id)
if not cart:
raise HTTPException(status_code=404, detail="Cart not found")
return cart
@router.post("/{cart_id}/items", response_model=CartResponse)
async def add_item(
cart_id: UUID,
item: CartItemCreate,
service: CartService = Depends(get_cart_service),
):
"""Add item to cart."""
cart = await service.add_item(cart_id, item)
if not cart:
raise HTTPException(status_code=404, detail="Cart not found")
return cart
@router.put("/{cart_id}/items/{item_id}", response_model=CartResponse)
async def update_item(
cart_id: UUID,
item_id: UUID,
update: CartItemUpdate,
service: CartService = Depends(get_cart_service),
):
"""Update cart item quantity."""
cart = await service.update_item(cart_id, item_id, update.quantity)
if not cart:
raise HTTPException(status_code=404, detail="Cart or item not found")
return cart
@router.delete("/{cart_id}/items/{item_id}", response_model=CartResponse)
async def remove_item(
cart_id: UUID,
item_id: UUID,
service: CartService = Depends(get_cart_service),
):
"""Remove item from cart."""
cart = await service.remove_item(cart_id, item_id)
if not cart:
raise HTTPException(status_code=404, detail="Cart or item not found")
return cart

View File

@ -0,0 +1,47 @@
"""Checkout API endpoints."""
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.order import CheckoutRequest, CheckoutResponse
from app.services.stripe_service import StripeService
from app.services.cart_service import CartService
router = APIRouter()
def get_stripe_service() -> StripeService:
return StripeService()
def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService:
return CartService(db)
@router.post("/session", response_model=CheckoutResponse)
async def create_checkout_session(
request: CheckoutRequest,
stripe_service: StripeService = Depends(get_stripe_service),
cart_service: CartService = Depends(get_cart_service),
):
"""Create a Stripe checkout session."""
# Get cart
cart = await cart_service.get_cart(request.cart_id)
if not cart:
raise HTTPException(status_code=404, detail="Cart not found")
if not cart.items:
raise HTTPException(status_code=400, detail="Cart is empty")
# Create Stripe session
result = await stripe_service.create_checkout_session(
cart=cart,
success_url=request.success_url,
cancel_url=request.cancel_url,
)
return CheckoutResponse(
checkout_url=result["url"],
session_id=result["session_id"],
)

View File

@ -0,0 +1,47 @@
"""Designs API endpoints."""
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from app.schemas.design import Design
from app.services.design_service import DesignService
router = APIRouter()
design_service = DesignService()
@router.get("", response_model=list[Design])
async def list_designs(
status: str = "active",
category: str | None = None,
):
"""List all designs."""
designs = await design_service.list_designs(status=status, category=category)
return designs
@router.get("/{slug}", response_model=Design)
async def get_design(slug: str):
"""Get a single design by slug."""
design = await design_service.get_design(slug)
if not design:
raise HTTPException(status_code=404, detail="Design not found")
return design
@router.get("/{slug}/image")
async def get_design_image(slug: str):
"""Serve the design image."""
image_path = await design_service.get_design_image_path(slug)
if not image_path or not Path(image_path).exists():
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(
image_path,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=86400", # Cache for 24 hours
},
)

11
backend/app/api/health.py Normal file
View File

@ -0,0 +1,11 @@
"""Health check endpoint."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}

53
backend/app/api/orders.py Normal file
View File

@ -0,0 +1,53 @@
"""Orders API endpoints."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.order import OrderResponse
from app.services.order_service import OrderService
router = APIRouter()
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(db)
@router.get("/{order_id}", response_model=OrderResponse)
async def get_order(
order_id: UUID,
email: str = Query(..., description="Email used for the order"),
service: OrderService = Depends(get_order_service),
):
"""Get order by ID (requires email verification)."""
order = await service.get_order_by_id_and_email(order_id, email)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.get("/{order_id}/tracking")
async def get_order_tracking(
order_id: UUID,
email: str = Query(..., description="Email used for the order"),
service: OrderService = Depends(get_order_service),
):
"""Get tracking information for an order."""
order = await service.get_order_by_id_and_email(order_id, email)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
tracking = []
for item in order.items:
if item.pod_tracking_number:
tracking.append({
"product": item.product_name,
"tracking_number": item.pod_tracking_number,
"tracking_url": item.pod_tracking_url,
"status": item.pod_status,
})
return {"order_id": order_id, "tracking": tracking}

View File

@ -0,0 +1,31 @@
"""Products API endpoints."""
from fastapi import APIRouter, HTTPException
from app.schemas.product import Product
from app.services.design_service import DesignService
router = APIRouter()
design_service = DesignService()
@router.get("", response_model=list[Product])
async def list_products(
category: str | None = None,
product_type: str | None = None,
):
"""List all products (designs with variants flattened for storefront)."""
products = await design_service.list_products(
category=category,
product_type=product_type,
)
return products
@router.get("/{slug}", response_model=Product)
async def get_product(slug: str):
"""Get a single product by slug."""
product = await design_service.get_product(slug)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product

View File

@ -0,0 +1,97 @@
"""Webhook endpoints for Stripe and POD providers."""
from fastapi import APIRouter, Request, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import get_settings
from app.services.stripe_service import StripeService
from app.services.order_service import OrderService
router = APIRouter()
settings = get_settings()
def get_stripe_service() -> StripeService:
return StripeService()
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(db)
@router.post("/stripe")
async def stripe_webhook(
request: Request,
stripe_service: StripeService = Depends(get_stripe_service),
order_service: OrderService = Depends(get_order_service),
):
"""Handle Stripe webhook events."""
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not sig_header:
raise HTTPException(status_code=400, detail="Missing signature")
try:
event = stripe_service.verify_webhook(payload, sig_header)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Handle events
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
await order_service.handle_successful_payment(session)
elif event["type"] == "payment_intent.payment_failed":
# Log failure, maybe send notification
pass
return {"status": "ok"}
@router.post("/prodigi")
async def prodigi_webhook(
request: Request,
order_service: OrderService = Depends(get_order_service),
):
"""Handle Prodigi webhook events."""
payload = await request.json()
event_type = payload.get("event")
order_data = payload.get("order", {})
if event_type in ["order.shipped", "order.complete"]:
await order_service.update_pod_status(
pod_provider="prodigi",
pod_order_id=order_data.get("id"),
status=event_type.replace("order.", ""),
tracking_number=order_data.get("shipments", [{}])[0].get("trackingNumber"),
tracking_url=order_data.get("shipments", [{}])[0].get("trackingUrl"),
)
return {"status": "ok"}
@router.post("/printful")
async def printful_webhook(
request: Request,
order_service: OrderService = Depends(get_order_service),
):
"""Handle Printful webhook events."""
payload = await request.json()
event_type = payload.get("type")
order_data = payload.get("data", {}).get("order", {})
if event_type in ["package_shipped", "order_fulfilled"]:
shipment = payload.get("data", {}).get("shipment", {})
await order_service.update_pod_status(
pod_provider="printful",
pod_order_id=str(order_data.get("id")),
status="shipped" if event_type == "package_shipped" else "fulfilled",
tracking_number=shipment.get("tracking_number"),
tracking_url=shipment.get("tracking_url"),
)
return {"status": "ok"}

66
backend/app/config.py Normal file
View File

@ -0,0 +1,66 @@
"""Application configuration."""
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Database
database_url: str = "postgresql://swag:devpassword@localhost:5432/swag"
# Redis
redis_url: str = "redis://localhost:6379"
# Stripe
stripe_secret_key: str = ""
stripe_publishable_key: str = ""
stripe_webhook_secret: str = ""
# POD Providers
prodigi_api_key: str = ""
printful_api_token: str = ""
pod_sandbox_mode: bool = True
# Auth
jwt_secret: str = "dev-secret-change-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24
# CORS
cors_origins: str = "http://localhost:3000"
# Paths
designs_path: str = "/app/designs"
config_path: str = "/app/config"
# App
app_name: str = "Mycopunk Swag Store"
debug: bool = False
@property
def designs_dir(self) -> Path:
return Path(self.designs_path)
@property
def config_dir(self) -> Path:
return Path(self.config_path)
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()

46
backend/app/database.py Normal file
View File

@ -0,0 +1,46 @@
"""Database configuration and session management."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
# Convert postgresql:// to postgresql+asyncpg:// for async
database_url = settings.database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(
database_url,
echo=settings.debug,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""Base class for SQLAlchemy models."""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency for getting database session."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

61
backend/app/main.py Normal file
View File

@ -0,0 +1,61 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.api import designs, products, cart, checkout, orders, webhooks, health
from app.api.admin import router as admin_router
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Startup
print(f"Starting {settings.app_name}...")
print(f"Designs path: {settings.designs_path}")
print(f"POD sandbox mode: {settings.pod_sandbox_mode}")
yield
# Shutdown
print("Shutting down...")
app = FastAPI(
title=settings.app_name,
description="E-commerce API for mycopunk merchandise",
version="0.1.0",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(designs.router, prefix="/api/designs", tags=["designs"])
app.include_router(products.router, prefix="/api/products", tags=["products"])
app.include_router(cart.router, prefix="/api/cart", tags=["cart"])
app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"])
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
@app.get("/")
async def root():
"""Root endpoint."""
return {
"name": settings.app_name,
"version": "0.1.0",
"docs": "/docs",
}

View File

@ -0,0 +1,17 @@
"""SQLAlchemy ORM models."""
from app.models.customer import Customer
from app.models.cart import Cart, CartItem
from app.models.order import Order, OrderItem
from app.models.admin import AdminUser
from app.models.product import ProductOverride
__all__ = [
"Customer",
"Cart",
"CartItem",
"Order",
"OrderItem",
"AdminUser",
"ProductOverride",
]

View File

@ -0,0 +1,24 @@
"""Admin user model."""
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AdminUser(Base):
"""Admin user model for authentication."""
__tablename__ = "admin_users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@ -0,0 +1,60 @@
"""Cart models."""
import uuid
from datetime import datetime, timedelta
from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def default_expiry():
return datetime.utcnow() + timedelta(days=7)
class Cart(Base):
"""Shopping cart model."""
__tablename__ = "carts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
expires_at: Mapped[datetime] = mapped_column(DateTime, default=default_expiry)
# Relationships
customer: Mapped["Customer | None"] = relationship("Customer", back_populates="carts")
items: Mapped[list["CartItem"]] = relationship(
"CartItem", back_populates="cart", cascade="all, delete-orphan"
)
class CartItem(Base):
"""Cart item model."""
__tablename__ = "cart_items"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
cart_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("carts.id", ondelete="CASCADE"), nullable=False
)
product_slug: Mapped[str] = mapped_column(String(100), nullable=False)
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
variant: Mapped[str | None] = mapped_column(String(50), nullable=True)
quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
cart: Mapped["Cart"] = relationship("Cart", back_populates="items")

View File

@ -0,0 +1,27 @@
"""Customer model."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Customer(Base):
"""Customer model for storing customer information."""
__tablename__ = "customers"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
carts: Mapped[list["Cart"]] = relationship("Cart", back_populates="customer")
orders: Mapped[list["Order"]] = relationship("Order", back_populates="customer")

102
backend/app/models/order.py Normal file
View File

@ -0,0 +1,102 @@
"""Order models."""
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class OrderStatus(str, Enum):
"""Order status enum."""
PENDING = "pending"
PAID = "paid"
PROCESSING = "processing"
PRINTING = "printing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REFUNDED = "refunded"
class Order(Base):
"""Order model."""
__tablename__ = "orders"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True
)
stripe_session_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
stripe_payment_intent_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(50), default=OrderStatus.PENDING.value)
# Shipping info
shipping_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
shipping_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
shipping_postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
shipping_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# Financials
subtotal: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
shipping_cost: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
tax: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
total: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), default="USD")
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
shipped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
customer: Mapped["Customer | None"] = relationship("Customer", back_populates="orders")
items: Mapped[list["OrderItem"]] = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
class OrderItem(Base):
"""Order item model."""
__tablename__ = "order_items"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
order_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False
)
product_slug: Mapped[str] = mapped_column(String(100), nullable=False)
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
variant: Mapped[str | None] = mapped_column(String(50), nullable=True)
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
# POD fulfillment
pod_provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
pod_order_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
pod_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
pod_tracking_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
pod_tracking_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
order: Mapped["Order"] = relationship("Order", back_populates="items")

View File

@ -0,0 +1,21 @@
"""Product override model."""
from datetime import datetime
from sqlalchemy import String, Boolean, Numeric, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ProductOverride(Base):
"""Product override model for visibility and price overrides."""
__tablename__ = "product_overrides"
slug: Mapped[str] = mapped_column(String(100), primary_key=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
price_override: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)

View File

@ -0,0 +1,28 @@
"""Pydantic schemas for API request/response models."""
from app.schemas.design import Design, DesignProduct, DesignSource
from app.schemas.product import Product, ProductVariant
from app.schemas.cart import (
CartCreate,
CartResponse,
CartItemCreate,
CartItemUpdate,
CartItemResponse,
)
from app.schemas.order import OrderResponse, OrderItemResponse, OrderStatus
__all__ = [
"Design",
"DesignProduct",
"DesignSource",
"Product",
"ProductVariant",
"CartCreate",
"CartResponse",
"CartItemCreate",
"CartItemUpdate",
"CartItemResponse",
"OrderResponse",
"OrderItemResponse",
"OrderStatus",
]

View File

@ -0,0 +1,57 @@
"""Cart schemas."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class CartItemCreate(BaseModel):
"""Request to add item to cart."""
product_slug: str
product_name: str
variant: str | None = None
quantity: int = 1
unit_price: float
class CartItemUpdate(BaseModel):
"""Request to update cart item."""
quantity: int
class CartItemResponse(BaseModel):
"""Cart item in response."""
id: UUID
product_slug: str
product_name: str
variant: str | None
quantity: int
unit_price: float
subtotal: float
class Config:
from_attributes = True
class CartCreate(BaseModel):
"""Request to create a new cart."""
pass
class CartResponse(BaseModel):
"""Cart response."""
id: UUID
items: list[CartItemResponse]
item_count: int
subtotal: float
created_at: datetime
expires_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,42 @@
"""Design schemas."""
from pydantic import BaseModel
class DesignSource(BaseModel):
"""Design source file information."""
file: str
format: str
dimensions: dict[str, int]
dpi: int
color_profile: str = "sRGB"
class DesignProduct(BaseModel):
"""Product configuration for a design."""
type: str
provider: str
sku: str
variants: list[str] = []
retail_price: float
class Design(BaseModel):
"""Design information from metadata.yaml."""
slug: str
name: str
description: str
tags: list[str] = []
category: str
author: str = ""
created: str = ""
source: DesignSource
products: list[DesignProduct] = []
status: str = "draft"
image_url: str = ""
class Config:
from_attributes = True

View File

@ -0,0 +1,76 @@
"""Order schemas."""
from datetime import datetime
from enum import Enum
from uuid import UUID
from pydantic import BaseModel
class OrderStatus(str, Enum):
"""Order status enum."""
PENDING = "pending"
PAID = "paid"
PROCESSING = "processing"
PRINTING = "printing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REFUNDED = "refunded"
class OrderItemResponse(BaseModel):
"""Order item in response."""
id: UUID
product_slug: str
product_name: str
variant: str | None
quantity: int
unit_price: float
pod_provider: str | None
pod_status: str | None
pod_tracking_number: str | None
pod_tracking_url: str | None
class Config:
from_attributes = True
class OrderResponse(BaseModel):
"""Order response."""
id: UUID
status: str
shipping_name: str | None
shipping_email: str | None
shipping_city: str | None
shipping_country: str | None
subtotal: float | None
shipping_cost: float | None
tax: float | None
total: float | None
currency: str
items: list[OrderItemResponse]
created_at: datetime
paid_at: datetime | None
shipped_at: datetime | None
class Config:
from_attributes = True
class CheckoutRequest(BaseModel):
"""Request to create checkout session."""
cart_id: UUID
success_url: str
cancel_url: str
class CheckoutResponse(BaseModel):
"""Checkout session response."""
checkout_url: str
session_id: str

View File

@ -0,0 +1,30 @@
"""Product schemas."""
from pydantic import BaseModel
class ProductVariant(BaseModel):
"""Product variant information."""
name: str
sku: str
provider: str
price: float
class Product(BaseModel):
"""Product for display in storefront."""
slug: str
name: str
description: str
category: str
product_type: str # sticker, shirt, print
tags: list[str] = []
image_url: str
base_price: float
variants: list[ProductVariant] = []
is_active: bool = True
class Config:
from_attributes = True

View File

@ -0,0 +1 @@
"""Business logic services."""

View File

@ -0,0 +1,94 @@
"""Analytics service for admin dashboard."""
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.order import Order, OrderItem, OrderStatus
class AnalyticsService:
"""Service for analytics and reporting."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_sales_summary(self, start_date: datetime) -> dict:
"""Get sales summary for the given period."""
# Total revenue
revenue_result = await self.db.execute(
select(func.sum(Order.total))
.where(
Order.created_at >= start_date,
Order.status.in_([
OrderStatus.PAID.value,
OrderStatus.PROCESSING.value,
OrderStatus.SHIPPED.value,
OrderStatus.DELIVERED.value,
]),
)
)
total_revenue = revenue_result.scalar() or 0
# Total orders
orders_result = await self.db.execute(
select(func.count(Order.id))
.where(Order.created_at >= start_date)
)
total_orders = orders_result.scalar() or 0
# Completed orders
completed_result = await self.db.execute(
select(func.count(Order.id))
.where(
Order.created_at >= start_date,
Order.status.in_([
OrderStatus.SHIPPED.value,
OrderStatus.DELIVERED.value,
]),
)
)
completed_orders = completed_result.scalar() or 0
# Average order value
avg_order = total_revenue / total_orders if total_orders > 0 else 0
return {
"total_revenue": float(total_revenue),
"total_orders": total_orders,
"completed_orders": completed_orders,
"average_order_value": float(avg_order),
"period_start": start_date.isoformat(),
}
async def get_product_performance(
self,
start_date: datetime,
limit: int = 10,
) -> list[dict]:
"""Get top performing products."""
result = await self.db.execute(
select(
OrderItem.product_slug,
OrderItem.product_name,
func.sum(OrderItem.quantity).label("total_quantity"),
func.sum(OrderItem.quantity * OrderItem.unit_price).label("total_revenue"),
)
.join(Order)
.where(Order.created_at >= start_date)
.group_by(OrderItem.product_slug, OrderItem.product_name)
.order_by(func.sum(OrderItem.quantity * OrderItem.unit_price).desc())
.limit(limit)
)
products = []
for row in result:
products.append({
"slug": row.product_slug,
"name": row.product_name,
"total_quantity": row.total_quantity,
"total_revenue": float(row.total_revenue),
})
return products

View File

@ -0,0 +1,90 @@
"""Authentication service for admin users."""
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from passlib.hash import bcrypt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db
from app.models.admin import AdminUser
settings = get_settings()
security = HTTPBearer()
class AuthService:
"""Service for admin authentication."""
def __init__(self, db: AsyncSession):
self.db = db
async def authenticate(self, email: str, password: str) -> str | None:
"""Authenticate admin user and return JWT token."""
result = await self.db.execute(
select(AdminUser).where(AdminUser.email == email)
)
admin = result.scalar_one_or_none()
if not admin or not admin.is_active:
return None
if not bcrypt.verify(password, admin.password_hash):
return None
# Create JWT token
expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours)
payload = {
"sub": str(admin.id),
"email": admin.email,
"exp": expire,
}
token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return token
async def verify_token(self, token: str) -> AdminUser | None:
"""Verify JWT token and return admin user."""
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
admin_id = payload.get("sub")
if not admin_id:
return None
result = await self.db.execute(
select(AdminUser).where(AdminUser.id == admin_id)
)
admin = result.scalar_one_or_none()
if not admin or not admin.is_active:
return None
return admin
except JWTError:
return None
@staticmethod
def hash_password(password: str) -> str:
"""Hash a password."""
return bcrypt.hash(password)
async def get_current_admin(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AdminUser:
"""Dependency to get current authenticated admin."""
auth_service = AuthService(db)
admin = await auth_service.verify_token(credentials.credentials)
if not admin:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
return admin

View File

@ -0,0 +1,142 @@
"""Cart service for managing shopping carts."""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.cart import Cart, CartItem
from app.schemas.cart import CartItemCreate, CartResponse, CartItemResponse
class CartService:
"""Service for cart operations."""
def __init__(self, db: AsyncSession):
self.db = db
async def create_cart(self) -> CartResponse:
"""Create a new shopping cart."""
cart = Cart()
self.db.add(cart)
await self.db.commit()
await self.db.refresh(cart)
return self._cart_to_response(cart)
async def get_cart(self, cart_id: UUID) -> CartResponse | None:
"""Get cart by ID."""
result = await self.db.execute(
select(Cart)
.where(Cart.id == cart_id)
.options(selectinload(Cart.items))
)
cart = result.scalar_one_or_none()
if not cart:
return None
return self._cart_to_response(cart)
async def add_item(
self,
cart_id: UUID,
item: CartItemCreate,
) -> CartResponse | None:
"""Add item to cart."""
result = await self.db.execute(
select(Cart)
.where(Cart.id == cart_id)
.options(selectinload(Cart.items))
)
cart = result.scalar_one_or_none()
if not cart:
return None
# Check if item already exists (same product + variant)
for existing in cart.items:
if (
existing.product_slug == item.product_slug
and existing.variant == item.variant
):
existing.quantity += item.quantity
await self.db.commit()
await self.db.refresh(cart)
return self._cart_to_response(cart)
# Add new item
cart_item = CartItem(
cart_id=cart_id,
product_slug=item.product_slug,
product_name=item.product_name,
variant=item.variant,
quantity=item.quantity,
unit_price=item.unit_price,
)
self.db.add(cart_item)
await self.db.commit()
await self.db.refresh(cart)
return self._cart_to_response(cart)
async def update_item(
self,
cart_id: UUID,
item_id: UUID,
quantity: int,
) -> CartResponse | None:
"""Update cart item quantity."""
result = await self.db.execute(
select(CartItem)
.where(CartItem.id == item_id, CartItem.cart_id == cart_id)
)
item = result.scalar_one_or_none()
if not item:
return None
if quantity <= 0:
await self.db.delete(item)
else:
item.quantity = quantity
await self.db.commit()
return await self.get_cart(cart_id)
async def remove_item(
self,
cart_id: UUID,
item_id: UUID,
) -> CartResponse | None:
"""Remove item from cart."""
result = await self.db.execute(
select(CartItem)
.where(CartItem.id == item_id, CartItem.cart_id == cart_id)
)
item = result.scalar_one_or_none()
if not item:
return None
await self.db.delete(item)
await self.db.commit()
return await self.get_cart(cart_id)
def _cart_to_response(self, cart: Cart) -> CartResponse:
"""Convert Cart model to response schema."""
items = [
CartItemResponse(
id=item.id,
product_slug=item.product_slug,
product_name=item.product_name,
variant=item.variant,
quantity=item.quantity,
unit_price=float(item.unit_price),
subtotal=float(item.unit_price) * item.quantity,
)
for item in cart.items
]
return CartResponse(
id=cart.id,
items=items,
item_count=sum(item.quantity for item in items),
subtotal=sum(item.subtotal for item in items),
created_at=cart.created_at,
expires_at=cart.expires_at,
)

View File

@ -0,0 +1,277 @@
"""Design service for reading designs from mycopunk-swag repo."""
from pathlib import Path
from functools import lru_cache
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.schemas.design import Design, DesignSource, DesignProduct
from app.schemas.product import Product, ProductVariant
from app.models.product import ProductOverride
settings = get_settings()
class DesignService:
"""Service for reading and managing designs."""
def __init__(self):
self.designs_path = settings.designs_dir
self._cache: dict[str, Design] = {}
def clear_cache(self):
"""Clear the design cache."""
self._cache.clear()
async def list_designs(
self,
status: str = "active",
category: str | None = None,
) -> list[Design]:
"""List all designs from the designs directory."""
designs = []
if not self.designs_path.exists():
return designs
for category_dir in self.designs_path.iterdir():
if not category_dir.is_dir():
continue
# Filter by category if specified
if category and category_dir.name != category:
continue
for design_dir in category_dir.iterdir():
if not design_dir.is_dir():
continue
design = await self._load_design(design_dir, category_dir.name)
if design and design.status == status:
designs.append(design)
return designs
async def get_design(self, slug: str) -> Design | None:
"""Get a single design by slug."""
# Check cache
if slug in self._cache:
return self._cache[slug]
# Search for the design
for category_dir in self.designs_path.iterdir():
if not category_dir.is_dir():
continue
design_dir = category_dir / slug
if design_dir.exists():
design = await self._load_design(design_dir, category_dir.name)
if design:
self._cache[slug] = design
return design
return None
async def get_design_image_path(self, slug: str) -> str | None:
"""Get the path to the design image file."""
design = await self.get_design(slug)
if not design:
return None
# Look for exported PNG first
for category_dir in self.designs_path.iterdir():
if not category_dir.is_dir():
continue
design_dir = category_dir / slug
if not design_dir.exists():
continue
# Check exports/300dpi first
export_path = design_dir / "exports" / "300dpi" / f"{slug}.png"
if export_path.exists():
return str(export_path)
# Check for source PNG
source_path = design_dir / design.source.file
if source_path.exists() and source_path.suffix.lower() == ".png":
return str(source_path)
# Check for any PNG in the directory
for png_file in design_dir.glob("*.png"):
return str(png_file)
return None
async def _load_design(self, design_dir: Path, category: str) -> Design | None:
"""Load a design from its directory."""
metadata_path = design_dir / "metadata.yaml"
if not metadata_path.exists():
return None
try:
with open(metadata_path) as f:
metadata = yaml.safe_load(f)
except Exception:
return None
if not metadata:
return None
slug = metadata.get("slug", design_dir.name)
# Parse source info
source_data = metadata.get("source", {})
source = DesignSource(
file=source_data.get("file", f"{slug}.svg"),
format=source_data.get("format", "svg"),
dimensions=source_data.get("dimensions", {"width": 0, "height": 0}),
dpi=source_data.get("dpi", 300),
color_profile=source_data.get("color_profile", "sRGB"),
)
# Parse products
products = []
for p in metadata.get("products", []):
products.append(
DesignProduct(
type=p.get("type", ""),
provider=p.get("provider", ""),
sku=p.get("sku", ""),
variants=p.get("variants", []),
retail_price=float(p.get("retail_price", 0)),
)
)
return Design(
slug=slug,
name=metadata.get("name", slug),
description=metadata.get("description", ""),
tags=metadata.get("tags", []),
category=category,
author=metadata.get("author", ""),
created=str(metadata.get("created", "")),
source=source,
products=products,
status=metadata.get("status", "draft"),
image_url=f"/api/designs/{slug}/image",
)
async def list_products(
self,
category: str | None = None,
product_type: str | None = None,
) -> list[Product]:
"""List all products (designs formatted for storefront)."""
designs = await self.list_designs(status="active", category=category)
products = []
for design in designs:
for dp in design.products:
# Filter by product type if specified
if product_type and dp.type != product_type:
continue
variants = [
ProductVariant(
name=v,
sku=f"{dp.sku}-{v}",
provider=dp.provider,
price=dp.retail_price,
)
for v in dp.variants
] if dp.variants else [
ProductVariant(
name="default",
sku=dp.sku,
provider=dp.provider,
price=dp.retail_price,
)
]
products.append(
Product(
slug=design.slug,
name=design.name,
description=design.description,
category=design.category,
product_type=dp.type,
tags=design.tags,
image_url=design.image_url,
base_price=dp.retail_price,
variants=variants,
is_active=True,
)
)
return products
async def get_product(self, slug: str) -> Product | None:
"""Get a single product by slug."""
design = await self.get_design(slug)
if not design or not design.products:
return None
# Use the first product configuration
dp = design.products[0]
variants = [
ProductVariant(
name=v,
sku=f"{dp.sku}-{v}",
provider=dp.provider,
price=dp.retail_price,
)
for v in dp.variants
] if dp.variants else [
ProductVariant(
name="default",
sku=dp.sku,
provider=dp.provider,
price=dp.retail_price,
)
]
return Product(
slug=design.slug,
name=design.name,
description=design.description,
category=design.category,
product_type=dp.type,
tags=design.tags,
image_url=design.image_url,
base_price=dp.retail_price,
variants=variants,
is_active=True,
)
async def set_product_override(
self,
db: AsyncSession,
slug: str,
is_active: bool | None = None,
price_override: float | None = None,
):
"""Set a product override in the database."""
# Check if override exists
result = await db.execute(
select(ProductOverride).where(ProductOverride.slug == slug)
)
override = result.scalar_one_or_none()
if override:
if is_active is not None:
override.is_active = is_active
if price_override is not None:
override.price_override = price_override
else:
override = ProductOverride(
slug=slug,
is_active=is_active if is_active is not None else True,
price_override=price_override,
)
db.add(override)
await db.commit()

View File

@ -0,0 +1,229 @@
"""Order management service."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.order import Order, OrderItem, OrderStatus
from app.models.customer import Customer
from app.models.cart import Cart
from app.schemas.order import OrderResponse, OrderItemResponse
class OrderService:
"""Service for order operations."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_order_by_id(self, order_id: UUID) -> OrderResponse | None:
"""Get order by ID."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
return self._order_to_response(order)
async def get_order_by_id_and_email(
self,
order_id: UUID,
email: str,
) -> OrderResponse | None:
"""Get order by ID with email verification."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id, Order.shipping_email == email)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
return self._order_to_response(order)
async def list_orders(
self,
status: OrderStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> list[OrderResponse]:
"""List orders with optional status filter."""
query = select(Order).options(selectinload(Order.items))
if status:
query = query.where(Order.status == status.value)
query = query.order_by(Order.created_at.desc()).limit(limit).offset(offset)
result = await self.db.execute(query)
orders = result.scalars().all()
return [self._order_to_response(o) for o in orders]
async def update_status(
self,
order_id: UUID,
status: OrderStatus,
) -> OrderResponse | None:
"""Update order status."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
order.status = status.value
if status == OrderStatus.SHIPPED:
order.shipped_at = datetime.utcnow()
elif status == OrderStatus.DELIVERED:
order.delivered_at = datetime.utcnow()
await self.db.commit()
return self._order_to_response(order)
async def handle_successful_payment(self, session: dict):
"""Handle successful Stripe payment."""
cart_id = session.get("metadata", {}).get("cart_id")
if not cart_id:
return
# Get cart
result = await self.db.execute(
select(Cart)
.where(Cart.id == UUID(cart_id))
.options(selectinload(Cart.items))
)
cart = result.scalar_one_or_none()
if not cart or not cart.items:
return
# Get or create customer
email = session.get("customer_details", {}).get("email", "")
customer = await self._get_or_create_customer(email)
# Get shipping details
shipping = session.get("shipping_details", {}) or {}
address = shipping.get("address", {}) or {}
# Create order
order = Order(
customer_id=customer.id if customer else None,
stripe_session_id=session.get("id"),
stripe_payment_intent_id=session.get("payment_intent"),
status=OrderStatus.PAID.value,
shipping_name=shipping.get("name"),
shipping_email=email,
shipping_address_line1=address.get("line1"),
shipping_address_line2=address.get("line2"),
shipping_city=address.get("city"),
shipping_state=address.get("state"),
shipping_postal_code=address.get("postal_code"),
shipping_country=address.get("country"),
subtotal=float(session.get("amount_subtotal", 0)) / 100,
shipping_cost=float(session.get("shipping_cost", {}).get("amount_total", 0)) / 100,
total=float(session.get("amount_total", 0)) / 100,
currency=session.get("currency", "usd").upper(),
paid_at=datetime.utcnow(),
)
self.db.add(order)
await self.db.flush()
# Create order items
for cart_item in cart.items:
order_item = OrderItem(
order_id=order.id,
product_slug=cart_item.product_slug,
product_name=cart_item.product_name,
variant=cart_item.variant,
quantity=cart_item.quantity,
unit_price=float(cart_item.unit_price),
pod_status="pending",
)
self.db.add(order_item)
await self.db.commit()
# TODO: Submit to POD providers
# TODO: Send confirmation email
async def update_pod_status(
self,
pod_provider: str,
pod_order_id: str,
status: str,
tracking_number: str | None = None,
tracking_url: str | None = None,
):
"""Update POD status for order items."""
await self.db.execute(
update(OrderItem)
.where(
OrderItem.pod_provider == pod_provider,
OrderItem.pod_order_id == pod_order_id,
)
.values(
pod_status=status,
pod_tracking_number=tracking_number,
pod_tracking_url=tracking_url,
)
)
await self.db.commit()
async def _get_or_create_customer(self, email: str) -> Customer | None:
"""Get or create customer by email."""
if not email:
return None
result = await self.db.execute(
select(Customer).where(Customer.email == email)
)
customer = result.scalar_one_or_none()
if customer:
return customer
customer = Customer(email=email)
self.db.add(customer)
await self.db.flush()
return customer
def _order_to_response(self, order: Order) -> OrderResponse:
"""Convert Order model to response schema."""
items = [
OrderItemResponse(
id=item.id,
product_slug=item.product_slug,
product_name=item.product_name,
variant=item.variant,
quantity=item.quantity,
unit_price=float(item.unit_price),
pod_provider=item.pod_provider,
pod_status=item.pod_status,
pod_tracking_number=item.pod_tracking_number,
pod_tracking_url=item.pod_tracking_url,
)
for item in order.items
]
return OrderResponse(
id=order.id,
status=order.status,
shipping_name=order.shipping_name,
shipping_email=order.shipping_email,
shipping_city=order.shipping_city,
shipping_country=order.shipping_country,
subtotal=float(order.subtotal) if order.subtotal else None,
shipping_cost=float(order.shipping_cost) if order.shipping_cost else None,
tax=float(order.tax) if order.tax else None,
total=float(order.total) if order.total else None,
currency=order.currency,
items=items,
created_at=order.created_at,
paid_at=order.paid_at,
shipped_at=order.shipped_at,
)

View File

@ -0,0 +1,91 @@
"""Stripe payment service."""
import stripe
from app.config import get_settings
from app.schemas.cart import CartResponse
settings = get_settings()
# Configure Stripe
stripe.api_key = settings.stripe_secret_key
class StripeService:
"""Service for Stripe operations."""
async def create_checkout_session(
self,
cart: CartResponse,
success_url: str,
cancel_url: str,
) -> dict:
"""Create a Stripe checkout session."""
line_items = []
for item in cart.items:
line_items.append({
"price_data": {
"currency": "usd",
"product_data": {
"name": item.product_name,
"description": f"Variant: {item.variant}" if item.variant else None,
},
"unit_amount": int(item.unit_price * 100), # Convert to cents
},
"quantity": item.quantity,
})
session = stripe.checkout.Session.create(
mode="payment",
line_items=line_items,
success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel_url,
shipping_address_collection={
"allowed_countries": [
"US", "CA", "GB", "AU", "DE", "FR", "NL", "BE", "AT", "CH",
"ES", "IT", "PT", "IE", "DK", "SE", "NO", "FI", "PL", "CZ",
],
},
metadata={
"cart_id": str(cart.id),
},
)
return {
"url": session.url,
"session_id": session.id,
}
def verify_webhook(self, payload: bytes, sig_header: str) -> dict:
"""Verify and parse Stripe webhook."""
try:
event = stripe.Webhook.construct_event(
payload,
sig_header,
settings.stripe_webhook_secret,
)
return event
except stripe.error.SignatureVerificationError as e:
raise ValueError(f"Invalid signature: {e}")
except Exception as e:
raise ValueError(f"Webhook error: {e}")
async def get_session(self, session_id: str) -> dict:
"""Get Stripe checkout session details."""
session = stripe.checkout.Session.retrieve(
session_id,
expand=["line_items", "customer"],
)
return session
async def create_refund(
self,
payment_intent_id: str,
amount: int | None = None,
) -> dict:
"""Create a refund for a payment."""
refund = stripe.Refund.create(
payment_intent=payment_intent_id,
amount=amount, # None = full refund
)
return refund

54
backend/pyproject.toml Normal file
View File

@ -0,0 +1,54 @@
[project]
name = "mycopunk-swag-store"
version = "0.1.0"
description = "E-commerce backend for mycopunk merchandise"
authors = [{ name = "Jeff Emmett", email = "jeff@mycofi.earth" }]
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"sqlalchemy>=2.0.0",
"alembic>=1.13.0",
"asyncpg>=0.29.0",
"psycopg2-binary>=2.9.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"httpx>=0.26.0",
"stripe>=7.0.0",
"pyyaml>=6.0.0",
"pillow>=10.0.0",
"python-multipart>=0.0.6",
"redis>=5.0.0",
"aiofiles>=23.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.26.0",
"black>=24.0.0",
"ruff>=0.1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

32
backend/requirements.txt Normal file
View File

@ -0,0 +1,32 @@
# Core
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
# Database
sqlalchemy>=2.0.0
alembic>=1.13.0
asyncpg>=0.29.0
psycopg2-binary>=2.9.0
# Validation
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Auth
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
# HTTP Client
httpx>=0.26.0
# Stripe
stripe>=7.0.0
# Config & Utils
pyyaml>=6.0.0
pillow>=10.0.0
python-multipart>=0.0.6
aiofiles>=23.0.0
# Cache
redis>=5.0.0

109
docker-compose.yml Normal file
View File

@ -0,0 +1,109 @@
version: "3.8"
services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: swag-db
restart: unless-stopped
environment:
POSTGRES_USER: swag
POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
POSTGRES_DB: swag
volumes:
- swag-db-data:/var/lib/postgresql/data
networks:
- swag-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U swag"]
interval: 5s
timeout: 5s
retries: 5
ports:
- "5432:5432" # Expose for local dev, remove in production
# Redis for sessions/cache (optional)
redis:
image: redis:7-alpine
container_name: swag-redis
restart: unless-stopped
volumes:
- swag-redis-data:/data
networks:
- swag-internal
ports:
- "6379:6379" # Expose for local dev
# FastAPI Backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: swag-backend
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://swag:${DB_PASSWORD:-devpassword}@db:5432/swag
- REDIS_URL=redis://redis:6379
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- PRODIGI_API_KEY=${PRODIGI_API_KEY}
- PRINTFUL_API_TOKEN=${PRINTFUL_API_TOKEN}
- POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true}
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- DESIGNS_PATH=/app/designs
- CONFIG_PATH=/app/config
volumes:
# Mount designs from mycopunk-swag repo
- ${DESIGNS_PATH:-../mycopunk-swag/designs}:/app/designs:ro
- ${CONFIG_PATH:-../mycopunk-swag/config}:/app/config:ro
depends_on:
db:
condition: service_healthy
networks:
- swag-internal
- traefik-public
ports:
- "8000:8000" # Expose for local dev
labels:
- "traefik.enable=true"
- "traefik.http.routers.swag-api.rule=Host(`swag.mycofi.earth`) && PathPrefix(`/api`)"
- "traefik.http.routers.swag-api.entrypoints=web"
- "traefik.http.services.swag-api.loadbalancer.server.port=8000"
- "traefik.docker.network=traefik-public"
# Next.js Frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
container_name: swag-frontend
restart: unless-stopped
environment:
- NODE_ENV=production
depends_on:
- backend
networks:
- swag-internal
- traefik-public
ports:
- "3000:3000" # Expose for local dev
labels:
- "traefik.enable=true"
- "traefik.http.routers.swag-web.rule=Host(`swag.mycofi.earth`)"
- "traefik.http.routers.swag-web.entrypoints=web"
- "traefik.http.services.swag-web.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
volumes:
swag-db-data:
swag-redis-data:
networks:
swag-internal:
driver: bridge
traefik-public:
external: true

45
frontend/Dockerfile Normal file
View File

@ -0,0 +1,45 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile || npm install
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

147
frontend/app/cart/page.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client";
import { useState, useEffect } from "react";
interface CartItem {
id: string;
product_slug: string;
product_name: string;
variant: string | null;
quantity: number;
unit_price: number;
subtotal: number;
}
interface Cart {
id: string;
items: CartItem[];
item_count: number;
subtotal: number;
}
export default function CartPage() {
const [cart, setCart] = useState<Cart | null>(null);
const [loading, setLoading] = useState(true);
const [checkingOut, setCheckingOut] = useState(false);
useEffect(() => {
const cartId = localStorage.getItem("cart_id");
if (cartId) {
fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/cart/${cartId}`
)
.then((res) => (res.ok ? res.json() : null))
.then(setCart)
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const handleCheckout = async () => {
if (!cart) return;
setCheckingOut(true);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/checkout/session`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cart_id: cart.id,
success_url: `${window.location.origin}/checkout/success`,
cancel_url: `${window.location.origin}/cart`,
}),
}
);
if (res.ok) {
const { checkout_url } = await res.json();
window.location.href = checkout_url;
}
} catch (error) {
console.error("Checkout error:", error);
} finally {
setCheckingOut(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<p>Loading cart...</p>
</div>
);
}
if (!cart || cart.items.length === 0) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Your Cart</h1>
<p className="text-muted-foreground mb-8">Your cart is empty.</p>
<a href="/products" className="text-primary hover:underline">
Continue Shopping
</a>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-8">Your Cart</h1>
<div className="grid md:grid-cols-3 gap-8">
<div className="md:col-span-2 space-y-4">
{cart.items.map((item) => (
<div
key={item.id}
className="flex items-center gap-4 p-4 border rounded-lg"
>
<div className="w-20 h-20 bg-muted rounded" />
<div className="flex-1">
<h3 className="font-semibold">{item.product_name}</h3>
{item.variant && (
<p className="text-sm text-muted-foreground">
Variant: {item.variant}
</p>
)}
<p className="text-sm">Qty: {item.quantity}</p>
</div>
<div className="text-right">
<p className="font-bold">${item.subtotal.toFixed(2)}</p>
</div>
</div>
))}
</div>
<div className="border rounded-lg p-6 h-fit">
<h2 className="font-bold mb-4">Order Summary</h2>
<div className="space-y-2 mb-4">
<div className="flex justify-between">
<span>Subtotal</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Shipping</span>
<span>Calculated at checkout</span>
</div>
</div>
<div className="border-t pt-4 mb-4">
<div className="flex justify-between font-bold">
<span>Total</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
</div>
<button
onClick={handleCheckout}
disabled={checkingOut}
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 disabled:opacity-50"
>
{checkingOut ? "Redirecting..." : "Checkout"}
</button>
</div>
</div>
</div>
);
}

59
frontend/app/globals.css Normal file
View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 142 76% 36%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 142 76% 36%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 142 70% 45%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 142 76% 36%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

46
frontend/app/layout.tsx Normal file
View File

@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Mycopunk Swag Store",
description: "Mycelial merchandise for the decentralized future",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<div className="min-h-screen flex flex-col">
<header className="border-b">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<a href="/" className="text-xl font-bold text-primary">
Mycopunk Swag
</a>
<nav className="flex items-center gap-6">
<a href="/products" className="hover:text-primary">
Products
</a>
<a href="/cart" className="hover:text-primary">
Cart
</a>
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t py-6">
<div className="container mx-auto px-4 text-center text-muted-foreground">
<p>&copy; 2026 Mycopunk. Build tools, not empires.</p>
</div>
</footer>
</div>
</body>
</html>
);
}

42
frontend/app/page.tsx Normal file
View File

@ -0,0 +1,42 @@
import Link from "next/link";
export default function HomePage() {
return (
<div className="container mx-auto px-4 py-16">
<div className="max-w-3xl mx-auto text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
Mycopunk Swag Store
</h1>
<p className="text-xl text-muted-foreground mb-8">
Mycelial merchandise for the decentralized future. Stickers, shirts,
and more featuring designs from the mycopunk movement.
</p>
<div className="flex gap-4 justify-center">
<Link
href="/products"
className="inline-flex items-center justify-center rounded-md bg-primary px-8 py-3 text-lg font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Browse Products
</Link>
</div>
</div>
<div className="mt-24">
<h2 className="text-2xl font-bold text-center mb-12">Featured</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<div className="aspect-square bg-muted flex items-center justify-center">
<span className="text-muted-foreground">Coming Soon</span>
</div>
<div className="p-4">
<h3 className="font-semibold">Build Tools Not Empires</h3>
<p className="text-sm text-muted-foreground">Sticker</p>
<p className="font-bold mt-2">$3.50</p>
</div>
</div>
{/* More products will be loaded from API */}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import Link from "next/link";
interface Product {
slug: string;
name: string;
description: string;
category: string;
product_type: string;
image_url: string;
base_price: number;
}
async function getProducts(): Promise<Product[]> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`,
{ next: { revalidate: 3600 } }
);
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Products</h1>
{products.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">
No products available yet. Check back soon!
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map((product) => (
<Link
key={product.slug}
href={`/products/${product.slug}`}
className="group"
>
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<div className="aspect-square bg-muted relative overflow-hidden">
<img
src={`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/designs/${product.slug}/image`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
</div>
<div className="p-4">
<h3 className="font-semibold truncate">{product.name}</h3>
<p className="text-sm text-muted-foreground capitalize">
{product.product_type}
</p>
<p className="font-bold mt-2">
${product.base_price.toFixed(2)}
</p>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}

18
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'swag.mycofi.earth',
},
{
protocol: 'http',
hostname: 'localhost',
},
],
},
};
export default nextConfig;

40
frontend/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "mycopunk-swag-store-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@stripe/stripe-js": "^2.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@ -0,0 +1,54 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;

27
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}