name: fullstack-feature description: Create a complete CRUD feature from scratch with backend API, database models, frontend UI, authentication, and permissions. Use for greenfield features where nothing exists yet. Most comprehensive skill covering complete fullstack implementation with PostgreSQL or MongoDB, all layers (models, repositories, services, endpoints, tests), type generation, frontend components, pages, routes, and i18n.
Fullstack Feature Implementation
Create a complete CRUD feature from scratch: backend API with database models, repositories, services, endpoints, and tests; type generation; frontend UI with API integration, components, pages, routes, and i18n. This is the most comprehensive workflow for greenfield features.
When to Use This Skill
- Creating a brand new feature from scratch
- Need both backend and frontend implementation
- Want complete CRUD operations (Create, Read, Update, Delete)
- Need proper auth and permissions
- Want production-ready code with tests
Don't use when:
- Backend already exists → Use
/api-to-uiskill - Just need backend OR frontend → Use
/backend-firstor layer-specific skills
Prerequisites
# Verify services running
docker compose ps
# Start if needed
docker compose up -d
# Verify backend accessible
curl http://localhost:8000/docs
# Verify frontend accessible
curl http://localhost:5173
Workflow Overview
5 Major Phases:
- Planning - Requirements, database choice, architecture decisions
- Backend - Models, repos, services, endpoints, tests, migrations
- Type Generation - OpenAPI → TypeScript types
- Frontend - API, hooks, schemas, components, pages, routes, i18n
- Integration Testing - E2E verification
Estimated time: 30-90 minutes depending on complexity
Phase 1: Planning & Requirements
Step 1.1: Feature Overview
Ask the user:
- Feature name: What are you building? (e.g., "products", "orders", "blog-posts")
- Description: What does this feature do? (brief summary)
- Complexity: Simple CRUD, moderate (with relationships), or complex (multiple related entities)?
Step 1.2: Data Requirements
Ask the user:
Fields needed:
- What fields does the main entity have?
- What are the data types? (string, number, boolean, date, UUID, etc.)
- Which fields are required vs optional?
- Any special validation? (min/max length, regex patterns, etc.)
- Any unique constraints? (email, SKU, slug, etc.)
Example:
Product:
- name (string, required, max 200 chars)
- description (string, optional)
- sku (string, required, unique, max 50 chars)
- price (decimal, required, positive, 2 decimal places)
- stock_quantity (integer, required, >= 0)
- is_active (boolean, default true)
- category_id (UUID, optional, FK to categories)
Step 1.3: Database Choice Decision
Ask about data characteristics to choose database:
Decision Tree
Does the data have:
├─ Fixed structure with relationships?
│ └─ Use PostgreSQL [X]
│
├─ Flexible/evolving schema?
│ └─ Use MongoDB [X]
│
├─ Mix of both (structured + flexible)?
│ └─ Use BOTH [X]
│ - PostgreSQL for core entities
│ - MongoDB for logs, events, metadata
│
└─ High write volume (logs, analytics)?
└─ Use MongoDB [X]
PostgreSQL - Best For
- User management, authentication
- Roles and permissions (RBAC)
- Transactional data (orders, payments)
- Data with relationships (products → categories)
- Data requiring ACID guarantees
- Complex queries with JOINs
Choose PostgreSQL when: Structured data, relationships, transactions
MongoDB - Best For
- Event logs and audit trails
- Analytics and metrics data
- Flexible schemas (varying structure)
- High write throughput
- Nested/hierarchical data (comments tree)
- Temporary/cache data
Choose MongoDB when: Flexible schema, high writes, nested data
Both - Best For
- Core data in PostgreSQL (users, products)
- Activity logs in MongoDB (user actions, events)
- Product catalog in PostgreSQL
- Product reviews in MongoDB (flexible metadata)
Step 1.4: Relationships
Ask the user:
- Does this entity relate to others?
- What's the relationship type? (one-to-many, many-to-many)
- Should deletions cascade or be prevented?
Examples:
- Product → Category (many-to-one)
- User → Orders (one-to-many)
- Products ↔ Tags (many-to-many via junction table)
Step 1.5: Permissions
Ask the user what permissions are needed:
Standard CRUD pattern:
{FEATURE}_READ- View/list items{FEATURE}_WRITE- Create and update items{FEATURE}_DELETE- Delete items
Example:
PRODUCTS_READ- Anyone can view productsPRODUCTS_WRITE- Only admins can create/editPRODUCTS_DELETE- Only owners can delete
Which roles need which permissions?
- Admin role: All permissions
- User role: READ only
- Manager role: READ + WRITE
Step 1.6: UI Requirements
Ask the user:
- List view: Yes/no, pagination needed?
- Detail view: Separate page or modal?
- Create: Modal or page?
- Edit: Inline, modal, or page?
- Delete: Confirmation dialog?
- Search/filter: Needed? By which fields?
- Bulk actions: Delete multiple, export, etc.?
Step 1.7: Create Implementation Plan
Based on answers, create a mental plan:
Simple Feature (e.g., tags):
- PostgreSQL: Single table, no relationships
- Permissions: READ (all), WRITE/DELETE (admin)
- UI: Simple list + create modal
Moderate Feature (e.g., products):
- PostgreSQL: Main table + relationship to categories
- Permissions: READ (all), WRITE/DELETE (admin)
- UI: List with search, detail page, create/edit forms
Complex Feature (e.g., orders with items):
- PostgreSQL: Orders table + OrderItems junction
- Permissions: Multiple (create own, read own vs all, admin actions)
- UI: List, detail with nested items, multi-step creation wizard
Phase 2: Backend Implementation
Work in backend/ directory for all backend steps.
Step 2.1: Create Model
For PostgreSQL
Create backend/app/models/{feature}.py:
from sqlalchemy import String, Text, Decimal, Integer, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from uuid import UUID, uuid4
from datetime import datetime
class Product(Base):
"""Product model for catalog management."""
__tablename__ = "products"
# Primary key (inherited from Base but shown for clarity)
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
# Required fields
name: Mapped[str] = mapped_column(String(200), index=True)
sku: Mapped[str] = mapped_column(String(50), unique=True, index=True)
price: Mapped[Decimal] = mapped_column(Decimal(10, 2))
stock_quantity: Mapped[int] = mapped_column(Integer, default=0)
# Optional fields
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Boolean flags
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Foreign keys
category_id: Mapped[UUID | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"),
nullable=True
)
# Relationships
category: Mapped["Category"] = relationship(back_populates="products")
# Timestamps (inherited from Base)
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
created_by_id: Mapped[UUID | None] # If tracking who created
def __repr__(self) -> str:
return f"<Product(id={self.id}, name={self.name})>"
Model conventions:
- Use
Mapped[]type hints - Add docstring describing purpose
- Include
__repr__for debugging - Use proper SQLAlchemy types:
String,Text,Decimal,Integer,Boolean,DateTime - Add indexes on frequently queried fields
- Use
unique=Truefor unique constraints - Use
nullable=Truefor optional fields - Define
ondeletebehavior for foreign keys - Use
back_populatesfor bidirectional relationships
For MongoDB
Create backend/app/models/mongodb/{feature}.py:
from beanie import Document
from pydantic import Field
from datetime import datetime
from uuid import UUID, uuid4
class Event(Document):
"""Event log document for analytics."""
# Fields
event_type: str = Field(..., max_length=100)
user_id: UUID
timestamp: datetime = Field(default_factory=datetime.utcnow)
metadata: dict = Field(default_factory=dict) # Flexible field
# Index configuration
class Settings:
name = "events" # Collection name
indexes = [
"event_type",
"user_id",
"timestamp",
]
def __repr__(self) -> str:
return f"<Event(type={self.event_type}, user={self.user_id})>"
Register in backend/app/db/mongodb.py:
from app.models.mongodb.event import Event
document_models = [
Event,
# ... other models
]
Step 2.2: Create Migration (PostgreSQL only)
Generate migration:
cd backend
# Auto-generate migration from model changes
alembic revision --autogenerate -m "Add {feature} model"
# Review generated migration in app/alembic/versions/
# Check: columns, indexes, foreign keys, constraints
# Apply migration
alembic upgrade head
# Verify
alembic current
Review checklist:
- All columns created with correct types
- Indexes added for frequently queried fields
- Foreign keys reference correct tables with proper ondelete
- Unique constraints applied where needed
- Default values set correctly
If migration looks wrong, edit manually or delete and regenerate.
Step 2.3: Add Permissions
Update backend/app/models/permission.py:
class Permission(str, Enum):
# ... existing permissions
# New feature permissions
PRODUCTS_READ = "products:read"
PRODUCTS_WRITE = "products:write"
PRODUCTS_DELETE = "products:delete"
Create migration to insert permissions:
alembic revision -m "Add {feature} permissions"
Edit the migration file:
from alembic import op
def upgrade():
op.execute("""
INSERT INTO permissions (name, description)
VALUES
('products:read', 'View products'),
('products:write', 'Create and update products'),
('products:delete', 'Delete products')
ON CONFLICT (name) DO NOTHING;
""")
# Optionally assign to roles
op.execute("""
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r, permissions p
WHERE r.name = 'admin'
AND p.name IN ('products:read', 'products:write', 'products:delete')
ON CONFLICT DO NOTHING;
""")
def downgrade():
op.execute("""
DELETE FROM permissions
WHERE name IN ('products:read', 'products:write', 'products:delete');
""")
Apply migration:
alembic upgrade head
Step 2.4: Create Schemas
Create backend/app/schemas/{feature}.py:
from pydantic import BaseModel, Field, field_validator
from uuid import UUID
from datetime import datetime
from decimal import Decimal
class ProductBase(BaseModel):
"""Shared fields for Product."""
name: str = Field(..., min_length=1, max_length=200)
description: str | None = Field(None, max_length=1000)
sku: str = Field(..., min_length=1, max_length=50)
price: Decimal = Field(..., gt=0, decimal_places=2)
stock_quantity: int = Field(..., ge=0)
category_id: UUID | None = None
@field_validator('sku')
@classmethod
def validate_sku(cls, v: str) -> str:
"""Ensure SKU is uppercase and alphanumeric."""
if not v.replace('-', '').isalnum():
raise ValueError('SKU must be alphanumeric')
return v.upper()
class ProductCreate(ProductBase):
"""Schema for creating a product."""
pass
class ProductUpdate(BaseModel):
"""Schema for updating a product (all fields optional)."""
name: str | None = Field(None, min_length=1, max_length=200)
description: str | None = None
sku: str | None = Field(None, min_length=1, max_length=50)
price: Decimal | None = Field(None, gt=0)
stock_quantity: int | None = Field(None, ge=0)
category_id: UUID | None = None
is_active: bool | None = None
class ProductResponse(ProductBase):
"""Schema for product responses."""
id: UUID
is_active: bool
created_at: datetime
updated_at: datetime
created_by_id: UUID | None
class Config:
from_attributes = True # Enable ORM mode
class ProductListResponse(BaseModel):
"""Schema for paginated product list."""
items: list[ProductResponse]
total: int
page: int
size: int
pages: int
Schema conventions:
Base- Shared fieldsCreate- Fields required for creationUpdate- All fields optional (partial updates)Response- Includes computed fields, timestamps, IDsListResponse- Paginated response wrapper- Use
Field()for validation - Add
field_validatorfor custom validation - Enable
from_attributes = Truefor ORM mode
Step 2.5: Create Repository
Create backend/app/repositories/{feature}_repository.py:
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete, func
from sqlalchemy.orm import selectinload
from app.models.{feature} import Product
from uuid import UUID
class ProductRepository:
"""Repository for Product database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get(self, product_id: UUID) -> Product | None:
"""Get product by ID."""
result = await self.session.execute(
select(Product)
.where(Product.id == product_id)
.options(selectinload(Product.category)) # Eager load relationship
)
return result.scalar_one_or_none()
async def get_paginated(
self,
offset: int = 0,
limit: int = 10,
filters: dict | None = None,
sort_by: str = "created_at",
order: str = "desc"
) -> list[Product]:
"""Get paginated list of products with filtering."""
query = select(Product).where(Product.is_active == True)
# Apply filters
if filters:
if "category_id" in filters and filters["category_id"]:
query = query.where(Product.category_id == filters["category_id"])
if "search" in filters and filters["search"]:
search_term = f"%{filters['search']}%"
query = query.where(
(Product.name.ilike(search_term)) |
(Product.sku.ilike(search_term))
)
if "min_price" in filters and filters["min_price"]:
query = query.where(Product.price >= filters["min_price"])
if "max_price" in filters and filters["max_price"]:
query = query.where(Product.price <= filters["max_price"])
if "in_stock" in filters and filters["in_stock"]:
query = query.where(Product.stock_quantity > 0)
# Apply sorting
sort_column = getattr(Product, sort_by, Product.created_at)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
# Apply pagination
query = query.offset(offset).limit(limit)
# Eager load relationships
query = query.options(selectinload(Product.category))
result = await self.session.execute(query)
return result.scalars().all()
async def count(self, filters: dict | None = None) -> int:
"""Count total products matching filters."""
query = select(func.count(Product.id)).where(Product.is_active == True)
# Apply same filters as get_paginated
if filters:
if "category_id" in filters and filters["category_id"]:
query = query.where(Product.category_id == filters["category_id"])
if "search" in filters and filters["search"]:
search_term = f"%{filters['search']}%"
query = query.where(
(Product.name.ilike(search_term)) |
(Product.sku.ilike(search_term))
)
if "min_price" in filters and filters["min_price"]:
query = query.where(Product.price >= filters["min_price"])
if "max_price" in filters and filters["max_price"]:
query = query.where(Product.price <= filters["max_price"])
if "in_stock" in filters and filters["in_stock"]:
query = query.where(Product.stock_quantity > 0)
result = await self.session.execute(query)
return result.scalar_one()
async def create(self, product: Product) -> Product:
"""Create a new product."""
self.session.add(product)
await self.session.commit()
await self.session.refresh(product)
return product
async def update(self, product_id: UUID, data: dict) -> Product | None:
"""Update product by ID."""
await self.session.execute(
update(Product)
.where(Product.id == product_id)
.values(**data, updated_at=func.now())
)
await self.session.commit()
return await self.get(product_id)
async def delete(self, product_id: UUID) -> bool:
"""Soft delete product (set is_active=False)."""
result = await self.session.execute(
update(Product)
.where(Product.id == product_id)
.values(is_active=False, updated_at=func.now())
)
await self.session.commit()
return result.rowcount > 0
async def hard_delete(self, product_id: UUID) -> bool:
"""Hard delete product from database."""
result = await self.session.execute(
delete(Product).where(Product.id == product_id)
)
await self.session.commit()
return result.rowcount > 0
Repository conventions:
- Take
AsyncSessionin constructor - Return model instances or None
- Use
scalar_one_or_none()for single results - Use
scalars().all()for lists - Implement pagination with offset/limit
- Add filtering capabilities
- Use soft delete by default (
is_active=False) - Provide
hard_deleteif needed - Use
selectinload()for eager loading relationships - Update
updated_aton modifications
Step 2.6: Create Service
Create backend/app/services/{feature}_service.py:
from app.repositories.{feature}_repository import ProductRepository
from app.schemas.{feature} import ProductCreate, ProductUpdate, ProductListResponse
from app.models.{feature} import Product
from uuid import UUID
class ProductService:
"""Service for product business logic."""
def __init__(self, repository: ProductRepository):
self.repository = repository
async def get_product(self, product_id: UUID) -> Product | None:
"""Get product by ID."""
return await self.repository.get(product_id)
async def list_products(
self,
page: int = 1,
size: int = 10,
filters: dict | None = None,
sort_by: str = "created_at",
order: str = "desc"
) -> ProductListResponse:
"""Get paginated list of products."""
offset = (page - 1) * size
products = await self.repository.get_paginated(
offset=offset,
limit=size,
filters=filters,
sort_by=sort_by,
order=order
)
total = await self.repository.count(filters=filters)
return ProductListResponse(
items=products,
total=total,
page=page,
size=size,
pages=(total + size - 1) // size
)
async def create_product(self, data: ProductCreate, created_by_id: UUID | None = None) -> Product:
"""Create a new product."""
product = Product(
**data.model_dump(),
created_by_id=created_by_id
)
return await self.repository.create(product)
async def update_product(
self,
product_id: UUID,
data: ProductUpdate
) -> Product | None:
"""Update an existing product."""
# Only update fields that were provided
update_data = data.model_dump(exclude_unset=True)
if not update_data:
# No fields to update, return current product
return await self.repository.get(product_id)
return await self.repository.update(product_id, update_data)
async def delete_product(self, product_id: UUID) -> bool:
"""Delete a product (soft delete)."""
return await self.repository.delete(product_id)
async def check_sku_exists(self, sku: str, exclude_id: UUID | None = None) -> bool:
"""Check if SKU already exists (for validation)."""
# Implementation depends on your needs
# This is a placeholder for business logic
return False
Service conventions:
- Take repository in constructor
- Implement business logic
- Handle validation that spans multiple entities
- Convert between schemas and models
- Calculate pagination details
- Don't handle HTTP concerns (that's in routes)
- Return models, not schemas
Step 2.7: Create API Endpoints
Create backend/app/api/v1/endpoints/{feature}.py:
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.schemas.{feature} import (
ProductCreate,
ProductUpdate,
ProductResponse,
ProductListResponse
)
from app.services.{feature}_service import ProductService
from app.repositories.{feature}_repository import ProductRepository
from app.common.dependencies import get_current_user, require_permissions
from app.models.permission import Permission
from app.models.user import User
from uuid import UUID
router = APIRouter(prefix="/products", tags=["products"])
def get_product_service(db: AsyncSession = Depends(get_db)) -> ProductService:
"""Dependency to get product service."""
repository = ProductRepository(db)
return ProductService(repository)
@router.get("", response_model=ProductListResponse)
@require_permissions(Permission.PRODUCTS_READ)
async def list_products(
page: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=100),
search: str | None = None,
category_id: UUID | None = None,
min_price: float | None = None,
max_price: float | None = None,
in_stock: bool | None = None,
sort_by: str = Query("created_at", regex="^(name|price|created_at|stock_quantity)$"),
order: str = Query("desc", regex="^(asc|desc)$"),
service: ProductService = Depends(get_product_service),
current_user: User = Depends(get_current_user)
):
"""
List products with pagination and filtering.
- **page**: Page number (starts at 1)
- **size**: Items per page (max 100)
- **search**: Search in name and SKU
- **category_id**: Filter by category
- **min_price/max_price**: Price range filter
- **in_stock**: Only show items in stock
- **sort_by**: Sort field
- **order**: Sort order (asc/desc)
"""
filters = {}
if search:
filters["search"] = search
if category_id:
filters["category_id"] = category_id
if min_price is not None:
filters["min_price"] = min_price
if max_price is not None:
filters["max_price"] = max_price
if in_stock is not None:
filters["in_stock"] = in_stock
return await service.list_products(
page=page,
size=size,
filters=filters,
sort_by=sort_by,
order=order
)
@router.get("/{product_id}", response_model=ProductResponse)
@require_permissions(Permission.PRODUCTS_READ)
async def get_product(
product_id: UUID,
service: ProductService = Depends(get_product_service),
current_user: User = Depends(get_current_user)
):
"""Get a single product by ID."""
product = await service.get_product(product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
return product
@router.post("", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
@require_permissions(Permission.PRODUCTS_WRITE)
async def create_product(
data: ProductCreate,
service: ProductService = Depends(get_product_service),
current_user: User = Depends(get_current_user)
):
"""Create a new product."""
return await service.create_product(data, created_by_id=current_user.id)
@router.put("/{product_id}", response_model=ProductResponse)
@require_permissions(Permission.PRODUCTS_WRITE)
async def update_product(
product_id: UUID,
data: ProductUpdate,
service: ProductService = Depends(get_product_service),
current_user: User = Depends(get_current_user)
):
"""Update an existing product."""
product = await service.update_product(product_id, data)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
return product
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_permissions(Permission.PRODUCTS_DELETE)
async def delete_product(
product_id: UUID,
service: ProductService = Depends(get_product_service),
current_user: User = Depends(get_current_user)
):
"""Delete a product (soft delete)."""
deleted = await service.delete_product(product_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
Register router in backend/app/api/v1/api.py:
from app.api.v1.endpoints import products
api_router.include_router(products.router)
Endpoint conventions:
- Use FastAPI dependency injection
- Apply
@require_permissions()decorator - Use
Query()for query parameters with validation - Return proper status codes (200, 201, 204, 404)
- Raise
HTTPExceptionwith detail messages - Use proper response models
- Include docstrings describing endpoint behavior
- Validate sort fields with regex
- Limit page size to prevent abuse
Step 2.8: Write Tests
Create backend/tests/integration/test_{feature}.py:
import pytest
from httpx import AsyncClient
from uuid import uuid4
@pytest.mark.asyncio
async def test_create_product(client: AsyncClient, admin_headers: dict):
"""Test creating a product."""
response = await client.post(
"/api/v1/products",
json={
"name": "Test Product",
"sku": "TEST-001",
"price": 29.99,
"stock_quantity": 100,
"description": "A test product"
},
headers=admin_headers
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Product"
assert data["sku"] == "TEST-001"
assert data["is_active"] is True
assert "id" in data
assert "created_at" in data
@pytest.mark.asyncio
async def test_list_products(client: AsyncClient, admin_headers: dict):
"""Test listing products with pagination."""
response = await client.get(
"/api/v1/products?page=1&size=10",
headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "size" in data
assert "pages" in data
assert isinstance(data["items"], list)
@pytest.mark.asyncio
async def test_get_product(client: AsyncClient, admin_headers: dict, product_id: str):
"""Test getting a single product."""
response = await client.get(
f"/api/v1/products/{product_id}",
headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == product_id
@pytest.mark.asyncio
async def test_get_product_not_found(client: AsyncClient, admin_headers: dict):
"""Test getting a non-existent product returns 404."""
fake_id = str(uuid4())
response = await client.get(
f"/api/v1/products/{fake_id}",
headers=admin_headers
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_product(client: AsyncClient, admin_headers: dict, product_id: str):
"""Test updating a product."""
response = await client.put(
f"/api/v1/products/{product_id}",
json={"price": 39.99, "stock_quantity": 50},
headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert data["price"] == 39.99
assert data["stock_quantity"] == 50
@pytest.mark.asyncio
async def test_delete_product(client: AsyncClient, admin_headers: dict, product_id: str):
"""Test deleting a product."""
response = await client.delete(
f"/api/v1/products/{product_id}",
headers=admin_headers
)
assert response.status_code == 204
# Verify product is soft deleted (not accessible)
response = await client.get(
f"/api/v1/products/{product_id}",
headers=admin_headers
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_list_products_with_search(client: AsyncClient, admin_headers: dict):
"""Test searching products."""
response = await client.get(
"/api/v1/products?search=test",
headers=admin_headers
)
assert response.status_code == 200
data = response.json()
# All returned items should match search
for item in data["items"]:
assert "test" in item["name"].lower() or "test" in item["sku"].lower()
@pytest.mark.asyncio
async def test_permission_check_read(client: AsyncClient, user_headers: dict):
"""Test that users without PRODUCTS_WRITE cannot create products."""
response = await client.post(
"/api/v1/products",
json={
"name": "Test",
"sku": "TEST",
"price": 10.00,
"stock_quantity": 1
},
headers=user_headers
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_validation_errors(client: AsyncClient, admin_headers: dict):
"""Test validation errors for invalid data."""
# Missing required field
response = await client.post(
"/api/v1/products",
json={"name": "Test"}, # Missing sku, price, stock_quantity
headers=admin_headers
)
assert response.status_code == 422
# Invalid price (negative)
response = await client.post(
"/api/v1/products",
json={
"name": "Test",
"sku": "TEST",
"price": -10.00, # Invalid
"stock_quantity": 1
},
headers=admin_headers
)
assert response.status_code == 422
Run tests:
cd backend
pytest tests/integration/test_{feature}.py -v
Phase 3: Type Generation
Step 3.1: Verify Backend Running
# Check backend is accessible
curl http://localhost:8000/docs
# Check OpenAPI schema is valid
curl http://localhost:8000/openapi.json | jq '.components.schemas' | grep -A 5 "{Feature}"
Step 3.2: Generate Types
cd frontend
npm run generate:types
This command:
- Fetches http://localhost:8000/openapi.json
- Generates TypeScript interfaces in
src/types/generated/api.ts - Creates Permission enum with new permissions
Step 3.3: Verify Types
# Check feature types exist
grep -A 10 "export interface ProductResponse" frontend/src/types/generated/api.ts
# Check permissions
grep "PRODUCTS_READ\|PRODUCTS_WRITE\|PRODUCTS_DELETE" frontend/src/types/generated/api.ts
If types missing: Check backend endpoint has response_model parameter.
Phase 4: Frontend Implementation
Work in frontend/ directory for all frontend steps.
Step 4.1: Create Feature Structure
cd frontend/src/features
mkdir -p {feature}/{api,hooks,schemas,components,pages}
Step 4.2: Create API Functions
Create frontend/src/features/{feature}/api/{feature}.ts:
import { apiClient } from '@/lib/api-client';
import type {
ProductResponse,
ProductCreate,
ProductUpdate,
ProductListResponse
} from '@/types/generated/api';
export const getProducts = async (params?: {
page?: number;
size?: number;
search?: string;
category_id?: string;
min_price?: number;
max_price?: number;
in_stock?: boolean;
sort_by?: string;
order?: 'asc' | 'desc';
}): Promise<ProductListResponse> => {
const { data } = await apiClient.get('/products', { params });
return data;
};
export const getProduct = async (id: string): Promise<ProductResponse> => {
const { data } = await apiClient.get(`/products/${id}`);
return data;
};
export const createProduct = async (
product: ProductCreate
): Promise<ProductResponse> => {
const { data } = await apiClient.post('/products', product);
return data;
};
export const updateProduct = async (
id: string,
product: ProductUpdate
): Promise<ProductResponse> => {
const { data } = await apiClient.put(`/products/${id}`, product);
return data;
};
export const deleteProduct = async (id: string): Promise<void> => {
await apiClient.delete(`/products/${id}`);
};
Step 4.3: Create React Query Hooks
Create frontend/src/features/{feature}/hooks/use{Feature}.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
getProducts,
getProduct,
createProduct,
updateProduct,
deleteProduct
} from '../api/products';
import type { ProductCreate, ProductUpdate } from '@/types/generated/api';
export const useProducts = (filters?: {
page?: number;
size?: number;
search?: string;
category_id?: string;
min_price?: number;
max_price?: number;
in_stock?: boolean;
sort_by?: string;
order?: 'asc' | 'desc';
}) => {
return useQuery({
queryKey: ['products', filters],
queryFn: () => getProducts(filters),
placeholderData: (prev) => prev,
});
};
export const useProduct = (id: string | undefined) => {
return useQuery({
queryKey: ['products', id],
queryFn: () => getProduct(id!),
enabled: !!id,
});
};
export const useCreateProduct = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ProductCreate) => createProduct(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
toast.success(t('products.messages.createSuccess'));
},
});
};
export const useUpdateProduct = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: ProductUpdate }) =>
updateProduct(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['products'] });
queryClient.invalidateQueries({ queryKey: ['products', variables.id] });
toast.success(t('products.messages.updateSuccess'));
},
});
};
export const useDeleteProduct = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteProduct(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
toast.success(t('products.messages.deleteSuccess'));
},
});
};
Step 4.4: Create Zod Schemas
Create frontend/src/features/{feature}/schemas/{feature}.ts:
import { z } from 'zod';
export const productSchema = z.object({
name: z.string().min(1, 'Name is required').max(200, 'Name is too long'),
description: z.string().max(1000, 'Description is too long').optional(),
sku: z.string().min(1, 'SKU is required').max(50, 'SKU is too long'),
price: z.number().positive('Price must be positive'),
stock_quantity: z.number().int().min(0, 'Stock cannot be negative'),
category_id: z.string().uuid().optional(),
});
export type ProductFormData = z.infer<typeof productSchema>;
Step 4.5: Create Components
(See api-to-ui skill SKILL.md sections 5.1-5.3 for complete component implementations)
Create:
ProductCard.tsx- Display item in card formatProductForm.tsx- Create/edit form with validationProductList.tsx- Display list with loading/error/empty states
Step 4.6: Create Pages
(See api-to-ui skill SKILL.md section 6 for complete page implementations)
Create:
ProductListPage.tsx- List with search, filter, pagination, create dialogProductDetailPage.tsx(optional) - Detail view with edit/delete
Step 4.7: Add Routes
(See api-to-ui skill SKILL.md section 6.3 for route implementation)
Add routes to frontend/src/routes/.
Step 4.8: Add Translations
(See api-to-ui skill SKILL.md section 7 for translation implementation)
Add i18n keys to:
frontend/src/locales/en/translation.jsonfrontend/src/locales/es/translation.json
Phase 5: Integration Testing
Step 5.1: Backend Tests
cd backend
pytest tests/integration/test_{feature}.py -v
# All tests should pass [X]
Step 5.2: Start Services
# Ensure both running
docker compose ps
# View logs if needed
docker compose logs -f backend frontend
Step 5.3: Manual Browser Testing
- Navigate to list page: http://localhost:5173/{feature}
- Test list view:
- Products load correctly
- Pagination works
- Search works
- Filters work
- Loading states display
- Test permissions:
- Login as admin → All buttons visible
- Login as user → Only read access
- Test create:
- Click "Add" button
- Fill form with valid data
- Submit → Success toast
- Item appears in list
- Test update:
- Click "Edit" on item
- Modify fields
- Submit → Success toast
- Changes reflect immediately
- Test delete:
- Click "Delete" on item
- Confirm dialog appears
- Confirm → Success toast
- Item removed from list
- Test validation:
- Try submit empty form → Validation errors
- Try invalid data → Errors display
- Test error handling:
- Stop backend:
docker compose stop backend - Try action → Error message displays
- Start backend → Verify recovery
Step 5.4: Integration Checklist
- Backend tests pass (pytest)
- Types generated correctly
- List page loads data
- Pagination works
- Search/filters work
- Create form works
- Update form works
- Delete with confirmation works
- Permission checks work (admin vs user)
- Loading states display correctly
- Error states display correctly
- Empty state displays
- Success toasts appear
- i18n works (switch language)
- Data refreshes after mutations
Completion
Final Steps
- Run all tests:
cd backend && pytest
cd ../frontend && npm test # if tests exist
- Code review:
- Review generated code
- Check for TODO comments
- Verify naming conventions
- Commit:
git add backend/app/models/{feature}.py \
backend/app/schemas/{feature}.py \
backend/app/repositories/{feature}_repository.py \
backend/app/services/{feature}_service.py \
backend/app/api/v1/endpoints/{feature}.py \
backend/tests/integration/test_{feature}.py \
backend/app/alembic/versions/*.py \
frontend/src/features/{feature}/ \
frontend/src/routes/{feature}.tsx \
frontend/src/types/generated/api.ts \
frontend/src/locales/*/translation.json
git commit -m "feat: Add {feature} complete CRUD feature
Backend:
- PostgreSQL model with relationships
- Repository with filtering and pagination
- Service with business logic
- CRUD endpoints with permissions
- Comprehensive test suite
- Alembic migrations
Frontend:
- API integration with React Query
- Zod validation schemas
- Components (Card, Form, List)
- Pages with routing
- Permission checks
- i18n (English + Spanish)
Permissions:
- {FEATURE}_READ (all users)
- {FEATURE}_WRITE (admins)
- {FEATURE}_DELETE (admins)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
Troubleshooting
(See backend-first and api-to-ui skills for detailed troubleshooting)
Common issues:
- Migration fails → Check model definitions
- Types not generated → Verify backend running and OpenAPI valid
- 401/403 errors → Check permissions assigned to roles
- Components not styled → Verify shadcn/ui installed
- i18n not working → Check keys match in translation files
Reference Documentation
- Backend patterns:
backend/docs/prompts/backend-patterns.md - Frontend patterns:
frontend/docs/prompts/frontend-patterns.md - Integration patterns:
docs/prompts/integration-patterns.md - Full workflow:
docs/FULLSTACK_WORKFLOW.md - Architecture:
docs/ARCHITECTURE.md
Success Criteria
[X] Backend:
- Model created with proper relationships
- Migration applied successfully
- Permissions added and assigned to roles
- All CRUD endpoints working
- Tests passing (100% coverage of endpoints)
[X] Frontend:
- Types generated from OpenAPI
- API functions using generated types
- React Query hooks implemented
- All components created (Card, Form, List)
- Pages created with routing
- Permission checks working
- i18n translations added
[X] Integration:
- End-to-end flow works
- Auth and permissions enforced
- Error handling working
- Loading states correct
- Data refreshes after mutations
- No console errors
The feature is production-ready!